From ba896dade35b1934805f03808098cdbf3a0aaab3 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 15:36:01 +0530
Subject: [PATCH 01/11] feat(solana): add immutable launch pool and invariant
tests
Implement fixed-supply token/SOL pools without active admin or withdrawal paths. Validate curve accounting and real compiled-program lifecycle behavior before any deployment.
---
.gitignore | 7 +
packages/solana-model/README.md | 36 +
packages/solana-model/differential.test.mjs | 70 +
packages/solana-model/golden-vectors.json | 66 +
packages/solana-model/model.test.mjs | 238 +
packages/solana-model/oracle.mjs | 125 +
packages/solana-model/package.json | 13 +
solana/.gitignore | 5 +
solana/ABI.md | 52 +
solana/Anchor.toml | 17 +
solana/BUILD.md | 74 +
solana/Cargo.lock | 5573 +++++++++++++++++++
solana/Cargo.toml | 13 +
solana/SECURITY.md | 38 +
solana/programs/launch_pool/Cargo.toml | 31 +
solana/programs/launch_pool/src/lib.rs | 774 +++
solana/programs/launch_pool/src/math.rs | 177 +
solana/programs/launch_pool/src/tests.rs | 1072 ++++
solana/runtime-tests/Cargo.toml | 33 +
solana/runtime-tests/tests/lifecycle.rs | 561 ++
solana/scripts/check-sbf.sh | 24 +
21 files changed, 8999 insertions(+)
create mode 100644 packages/solana-model/README.md
create mode 100644 packages/solana-model/differential.test.mjs
create mode 100644 packages/solana-model/golden-vectors.json
create mode 100644 packages/solana-model/model.test.mjs
create mode 100644 packages/solana-model/oracle.mjs
create mode 100644 packages/solana-model/package.json
create mode 100644 solana/.gitignore
create mode 100644 solana/ABI.md
create mode 100644 solana/Anchor.toml
create mode 100644 solana/BUILD.md
create mode 100644 solana/Cargo.lock
create mode 100644 solana/Cargo.toml
create mode 100644 solana/SECURITY.md
create mode 100644 solana/programs/launch_pool/Cargo.toml
create mode 100644 solana/programs/launch_pool/src/lib.rs
create mode 100644 solana/programs/launch_pool/src/math.rs
create mode 100644 solana/programs/launch_pool/src/tests.rs
create mode 100644 solana/runtime-tests/Cargo.toml
create mode 100644 solana/runtime-tests/tests/lifecycle.rs
create mode 100644 solana/scripts/check-sbf.sh
diff --git a/.gitignore b/.gitignore
index 1a88551d..bfdccd9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,13 @@ broadcast/
.DS_Store
*.tsbuildinfo
+# Solana build products and local-only test/deployment keys
+solana/target/
+solana/.anchor/
+solana/.tools/
+**/*-keypair.json
+app/packages/solana-sdk/dist/
+
# editor/agent scratch + local deploy metadata
AGENTS.md
CLAUDE.md
diff --git a/packages/solana-model/README.md b/packages/solana-model/README.md
new file mode 100644
index 00000000..42b05f47
--- /dev/null
+++ b/packages/solana-model/README.md
@@ -0,0 +1,36 @@
+# Independent Solana pool model
+
+This is a dependency-free specification oracle and reproducible regression suite, not a production quote library or a security audit.
+
+Run from this directory with Node 22:
+
+```sh
+npm test
+npm run test:sdk
+```
+
+The first command uses only Node built-ins. The SDK differential command uses Node's TypeScript stripping to load the production SDK's pure math module; it does not require a wallet, RPC, private key, network request, or an on-chain deployment.
+
+## Independence
+
+The oracle deliberately does not import or copy the program/SDK's closed-form trade quotients. It binary-searches the largest output preserving the **previous** constant product. The sell search extends through the effective reserve `R + V`, not just real reserves `R`, then independently asserts that the result cannot spend virtual SOL. Clipping the search to `R` would hide a broken solvency model.
+
+Fees are the smallest integer meeting the fee inequality. Beneficiary entitlements use an unbounded integer search, so tests exercise lifetime values that would overflow a naive `u128 * weight` implementation. The production implementation instead uses checked quotient/remainder decomposition.
+
+The shared JSON golden vectors encode every amount as a decimal string. Do not regenerate them from production math when a regression appears; investigate the disagreement.
+
+## Checked properties
+
+- 90,000 deterministic accepted mixed trades across all three fee tiers and minimum, middle and maximum virtual offsets. The test asserts substantial coverage of both trade directions, avoiding pseudo-random low-bit selection bias.
+- Conservation of token supply and actual SOL balances, separate rent floors, real reserves, fee obligations and unsolicited donations.
+- Transfers between holders, token burns, token/SOL/fee-vault donations, repeated fee claims, and independent recipient payouts.
+- Nondecreasing constant product and maximal integer output on every accepted trade.
+- 3,600 isolated round trips, covering buy/sell and sell/buy, cannot create trader value before external incentives.
+- Tiny-input fee ceilings, zero outputs, slippage, malformed states, near-empty inventory, `u64` reserve bounds and `u128` cumulative fees.
+- 108,000 differential state transitions against the production TypeScript SDK, plus fee and rejection boundary comparisons.
+
+## What these tests do not establish
+
+These are mathematical and balance-sheet tests. They do not execute Solana instructions or prove account ownership, signer validation, CPI safety, PDA initialization under prefunding, expiry enforcement, runtime rent behavior, activation atomicity, deployment immutability, packet/compute limits, wallet compatibility or indexing correctness. Those require separate program/runtime, SDK and integration tests.
+
+Passing this suite does not approve the virtual-offset valuation policy, establish market demand, make virtual SOL withdrawable, guarantee buyer returns, complete an independent audit, or authorize mainnet deployment.
diff --git a/packages/solana-model/differential.test.mjs b/packages/solana-model/differential.test.mjs
new file mode 100644
index 00000000..a7785642
--- /dev/null
+++ b/packages/solana-model/differential.test.mjs
@@ -0,0 +1,70 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { claimableFees, feeEntitlement, quoteBuy, quoteSell, tradingFee } from "../../app/packages/solana-sdk/src/math.ts";
+import {
+ SUPPLY, U64_MAX, U128_MAX, MIN_VIRTUAL_SOL, MAX_VIRTUAL_SOL,
+ claimOracle, entitlementOracle, feeOracle, nextState, quoteBuyOracle, quoteSellOracle,
+} from "./oracle.mjs";
+
+test("SDK agrees with independently solved invariant across 108,000 state transitions", () => {
+ let seed = 2_910_317n;
+ const random = () => {
+ seed ^= seed << 13n;
+ seed ^= seed >> 7n;
+ seed ^= seed << 17n;
+ seed &= U64_MAX;
+ return seed;
+ };
+ let buys = 0;
+ let sells = 0;
+ for (const virtualSol of [MIN_VIRTUAL_SOL, 30_000_000_000n, MAX_VIRTUAL_SOL]) {
+ for (const feeBps of [0, 100, 300]) {
+ let state = { tokenInventory: SUPPLY, realSolReserves: 0n, virtualSol, feeBps };
+ for (let step = 0; step < 12_000; step++) {
+ const circulating = SUPPLY - state.tokenInventory;
+ const buy = circulating === 0n || random() % 2n === 0n;
+ const input = buy ? 1_000_000n + random() % 1_000_000_000_000n : circulating * (1n + random() % 99n) / 100n;
+ const oracle = (buy ? quoteBuyOracle : quoteSellOracle)(state, input);
+ const actual = (buy ? quoteBuy : quoteSell)(state, input);
+ assert.deepEqual(actual, oracle, `fee=${feeBps}, V=${virtualSol}, step=${step}`);
+ state = nextState(state, oracle);
+ if (buy) buys++;
+ else sells++;
+ }
+ }
+ }
+ assert.equal(buys + sells, 108_000);
+ assert(buys > 40_000 && sells > 40_000);
+});
+
+test("SDK fee ledger agrees at lifetime and tiny-value boundaries", () => {
+ for (const fee of [0, 100, 300]) for (const amount of [0n, 1n, 2n, 33n, 34n, 99n, 100n, 101n, U64_MAX]) {
+ assert.equal(tradingFee(amount, fee), feeOracle(amount, fee));
+ }
+ for (const earned of [0n, 1n, 9999n, 10000n, U64_MAX, U64_MAX + 1n, U128_MAX]) for (const weight of [1, 17, 3333, 3334, 9999, 10000]) {
+ const expected = entitlementOracle(earned, weight);
+ assert.equal(feeEntitlement(earned, weight), expected);
+ assert.equal(claimableFees(earned, weight, expected / 2n), claimOracle(earned, weight, expected / 2n));
+ }
+});
+
+test("SDK and oracle both reject invalid states and unsafe quotes", () => {
+ const initial = { tokenInventory: SUPPLY, realSolReserves: 0n, virtualSol: MIN_VIRTUAL_SOL, feeBps: 0 };
+ for (const patch of [
+ { tokenInventory: 0n }, { tokenInventory: SUPPLY + 1n }, { tokenInventory: SUPPLY - 1n },
+ { realSolReserves: -1n }, { realSolReserves: U64_MAX },
+ { virtualSol: MIN_VIRTUAL_SOL - 1n }, { virtualSol: MAX_VIRTUAL_SOL + 1n }, { feeBps: 301 },
+ ]) {
+ const state = { ...initial, ...patch };
+ assert.throws(() => quoteBuy(state, 1_000_000n));
+ assert.throws(() => quoteBuyOracle(state, 1_000_000n));
+ }
+ for (const input of [0n, -1n, U64_MAX, U64_MAX + 1n]) {
+ assert.throws(() => quoteBuy(initial, input));
+ assert.throws(() => quoteBuyOracle(initial, input));
+ }
+ assert.throws(() => quoteBuy(initial, 1_000_000n, SUPPLY));
+ assert.throws(() => quoteBuyOracle(initial, 1_000_000n, SUPPLY));
+ assert.throws(() => quoteSell(initial, 1n));
+ assert.throws(() => quoteSellOracle(initial, 1n));
+});
diff --git a/packages/solana-model/golden-vectors.json b/packages/solana-model/golden-vectors.json
new file mode 100644
index 00000000..e5962cf5
--- /dev/null
+++ b/packages/solana-model/golden-vectors.json
@@ -0,0 +1,66 @@
+[
+ {
+ "name": "buy-1sol-fee0",
+ "side": "buy",
+ "state": { "tokenInventory": "1000000000000000", "realSolReserves": "0", "virtualSol": "30000000000", "feeBps": 0 },
+ "input": "1000000000",
+ "minOutput": "1",
+ "expected": { "amountOut": "32258064516129", "fee": "0", "nextTokenInventory": "967741935483871", "nextRealSolReserves": "1000000000" }
+ },
+ {
+ "name": "sell-all-fee0",
+ "side": "sell",
+ "state": { "tokenInventory": "967741935483871", "realSolReserves": "1000000000", "virtualSol": "30000000000", "feeBps": 0 },
+ "input": "32258064516129",
+ "minOutput": "1",
+ "expected": { "amountOut": "999999999", "fee": "0", "nextTokenInventory": "1000000000000000", "nextRealSolReserves": "1" }
+ },
+ {
+ "name": "buy-1sol-fee100",
+ "side": "buy",
+ "state": { "tokenInventory": "1000000000000000", "realSolReserves": "0", "virtualSol": "30000000000", "feeBps": 100 },
+ "input": "1000000000",
+ "minOutput": "1",
+ "expected": { "amountOut": "31945788964181", "fee": "10000000", "nextTokenInventory": "968054211035819", "nextRealSolReserves": "990000000" }
+ },
+ {
+ "name": "sell-all-fee100",
+ "side": "sell",
+ "state": { "tokenInventory": "968054211035819", "realSolReserves": "990000000", "virtualSol": "30000000000", "feeBps": 100 },
+ "input": "31945788964181",
+ "minOutput": "1",
+ "expected": { "amountOut": "980099999", "fee": "9900000", "nextTokenInventory": "1000000000000000", "nextRealSolReserves": "1" }
+ },
+ {
+ "name": "buy-1sol-fee300",
+ "side": "buy",
+ "state": { "tokenInventory": "1000000000000000", "realSolReserves": "0", "virtualSol": "30000000000", "feeBps": 300 },
+ "input": "1000000000",
+ "minOutput": "1",
+ "expected": { "amountOut": "31320632870519", "fee": "30000000", "nextTokenInventory": "968679367129481", "nextRealSolReserves": "970000000" }
+ },
+ {
+ "name": "sell-all-fee300",
+ "side": "sell",
+ "state": { "tokenInventory": "968679367129481", "realSolReserves": "970000000", "virtualSol": "30000000000", "feeBps": 300 },
+ "input": "31320632870519",
+ "minOutput": "1",
+ "expected": { "amountOut": "940899999", "fee": "29100000", "nextTokenInventory": "1000000000000000", "nextRealSolReserves": "1" }
+ },
+ {
+ "name": "minimum-offset-dust",
+ "side": "buy",
+ "state": { "tokenInventory": "1000000000000000", "realSolReserves": "0", "virtualSol": "1000000000", "feeBps": 300 },
+ "input": "2",
+ "minOutput": "1",
+ "expected": { "amountOut": "999999", "fee": "1", "nextTokenInventory": "999999999000001", "nextRealSolReserves": "1" }
+ },
+ {
+ "name": "maximum-offset-large",
+ "side": "buy",
+ "state": { "tokenInventory": "1000000000000000", "realSolReserves": "0", "virtualSol": "1000000000000000", "feeBps": 300 },
+ "input": "5000000000000000000",
+ "minOutput": "1",
+ "expected": { "amountOut": "999793856936714", "fee": "150000000000000000", "nextTokenInventory": "206143063286", "nextRealSolReserves": "4850000000000000000" }
+ }
+]
diff --git a/packages/solana-model/model.test.mjs b/packages/solana-model/model.test.mjs
new file mode 100644
index 00000000..fc89dc82
--- /dev/null
+++ b/packages/solana-model/model.test.mjs
@@ -0,0 +1,238 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import {
+ SUPPLY, U64_MAX, U128_MAX, MIN_VIRTUAL_SOL, MAX_VIRTUAL_SOL,
+ checkState, claimOracle, entitlementOracle, feeOracle, nextState,
+ quoteBuyOracle, quoteSellOracle,
+} from "./oracle.mjs";
+
+const OFFSETS = [MIN_VIRTUAL_SOL, 30_000_000_000n, MAX_VIRTUAL_SOL];
+const FEES = [0, 100, 300];
+const initial = (virtualSol = OFFSETS[1], feeBps = 100) => ({
+ tokenInventory: SUPPLY, realSolReserves: 0n, virtualSol, feeBps,
+});
+const sum = (values) => values.reduce((a, b) => a + b, 0n);
+const product = (state) => state.tokenInventory * (state.realSolReserves + state.virtualSol);
+function random(seed) {
+ let value = seed >>> 0;
+ return () => {
+ value = (Math.imul(value, 1_664_525) + 1_013_904_223) >>> 0;
+ return BigInt(value);
+ };
+}
+
+// Independent balance sheet, including ordinary transfers, burns and donations.
+// These are specification tests, not a simulation of Solana account execution.
+class Ledger {
+ constructor(virtualSol, feeBps) {
+ this.state = initial(virtualSol, feeBps);
+ this.walletSol = Array(7).fill(1_000_000_000_000_000n);
+ this.walletTokens = Array(7).fill(0n);
+ this.weights = [1, 3, 17, 999, 2000, 3000, 3980];
+ this.paid = Array(7).fill(0n);
+ this.feesEarned = 0n;
+ this.reserveRent = 1_234_560n;
+ this.feeRent = 987_650n;
+ this.reserveBalance = this.reserveRent;
+ this.feeBalance = this.feeRent;
+ this.tokenBalance = SUPPLY;
+ this.solDonations = 0n;
+ this.feeDonations = 0n;
+ this.tokenDonations = 0n;
+ this.burned = 0n;
+ this.initialSol = sum(this.walletSol) + this.reserveBalance + this.feeBalance;
+ }
+ buy(wallet, input) {
+ const quote = quoteBuyOracle(this.state, input);
+ assert(this.walletSol[wallet] >= input);
+ const before = product(this.state);
+ this.walletSol[wallet] -= input;
+ this.walletTokens[wallet] += quote.amountOut;
+ this.reserveBalance += quote.netInput;
+ this.feeBalance += quote.fee;
+ this.tokenBalance -= quote.amountOut;
+ this.feesEarned += quote.fee;
+ this.state = nextState(this.state, quote);
+ assert(product(this.state) >= before);
+ // One extra token atom would break the old product. This proves maximality.
+ assert((this.state.tokenInventory - 1n) * (this.state.realSolReserves + this.state.virtualSol) < before);
+ }
+ sell(wallet, input) {
+ const quote = quoteSellOracle(this.state, input);
+ assert(this.walletTokens[wallet] >= input);
+ const before = product(this.state);
+ this.walletSol[wallet] += quote.amountOut;
+ this.walletTokens[wallet] -= input;
+ this.reserveBalance -= quote.amountOut + quote.fee;
+ this.feeBalance += quote.fee;
+ this.tokenBalance += input;
+ this.feesEarned += quote.fee;
+ this.state = nextState(this.state, quote);
+ assert(product(this.state) >= before);
+ assert(this.state.tokenInventory * (this.state.realSolReserves + this.state.virtualSol - 1n) < before);
+ }
+ claim(wallet) {
+ const amount = claimOracle(this.feesEarned, this.weights[wallet], this.paid[wallet]);
+ const before = { ...this.state };
+ this.paid[wallet] += amount;
+ this.feeBalance -= amount;
+ this.walletSol[wallet] += amount;
+ assert.deepEqual(this.state, before);
+ assert.equal(claimOracle(this.feesEarned, this.weights[wallet], this.paid[wallet]), 0n);
+ }
+ donate(wallet) {
+ const before = { ...this.state };
+ this.walletSol[wallet] -= 123n;
+ this.reserveBalance += 100n;
+ this.solDonations += 100n;
+ this.feeBalance += 23n;
+ this.feeDonations += 23n;
+ if (this.walletTokens[wallet] > 3n) {
+ this.walletTokens[wallet] -= 3n;
+ this.tokenBalance += 2n;
+ this.tokenDonations += 2n;
+ this.burned += 1n;
+ }
+ assert.deepEqual(this.state, before);
+ }
+ transfer(from, to) {
+ const amount = this.walletTokens[from] / 5n;
+ this.walletTokens[from] -= amount;
+ this.walletTokens[to] += amount;
+ }
+ check() {
+ checkState(this.state);
+ assert.equal(sum(this.walletSol) + this.reserveBalance + this.feeBalance, this.initialSol);
+ assert.equal(sum(this.walletTokens) + this.tokenBalance + this.burned, SUPPLY);
+ assert.equal(this.tokenBalance, this.state.tokenInventory + this.tokenDonations);
+ assert.equal(this.reserveBalance, this.reserveRent + this.state.realSolReserves + this.solDonations);
+ assert.equal(this.feeBalance, this.feeRent + this.feesEarned - sum(this.paid) + this.feeDonations);
+ assert(this.reserveBalance >= this.reserveRent);
+ assert(this.feeBalance >= this.feeRent);
+ assert(this.feesEarned <= U128_MAX);
+ this.paid.forEach((paid, i) => assert(paid <= entitlementOracle(this.feesEarned, this.weights[i])));
+ }
+}
+
+test("independent golden vectors remain stable", async () => {
+ const vectors = JSON.parse(await readFile(new URL("./golden-vectors.json", import.meta.url), "utf8"));
+ for (const vector of vectors) {
+ const state = Object.fromEntries(Object.entries(vector.state).map(([key, value]) =>
+ [key, key === "feeBps" ? value : BigInt(value)]));
+ const quote = (vector.side === "buy" ? quoteBuyOracle : quoteSellOracle)(state, BigInt(vector.input), BigInt(vector.minOutput));
+ for (const [key, value] of Object.entries(vector.expected)) assert.equal(quote[key], BigInt(value), `${vector.name}: ${key}`);
+ }
+});
+
+test("90,000 deterministic mixed trades conserve balances with fees, donations, burns and transfers", () => {
+ let accepted = 0;
+ let buys = 0;
+ let sells = 0;
+ for (const offset of OFFSETS) for (const fee of FEES) for (let scenario = 0; scenario < 40; scenario++) {
+ const rng = random(29_103 + scenario * 7_919 + fee);
+ const ledger = new Ledger(offset, fee);
+ for (let step = 0; step < 250; step++) {
+ const wallet = Number(rng() % 7n);
+ if ((rng() >> 16n) % 2n === 0n || ledger.walletTokens[wallet] === 0n) {
+ ledger.buy(wallet, 1_000_000n + rng() * 29n);
+ buys++;
+ } else {
+ const input = ledger.walletTokens[wallet] * (1n + rng() % 99n) / 100n;
+ // Generated positions comfortably exceed one lamport at every offset.
+ ledger.sell(wallet, input);
+ sells++;
+ }
+ accepted++;
+ if (step % 11 === 0) ledger.claim(Number(rng() % 7n));
+ if (step % 17 === 0) ledger.donate(wallet);
+ if (step % 23 === 0) ledger.transfer(wallet, (wallet + 1) % 7);
+ if (step % 10 === 0) ledger.check();
+ }
+ for (let i = 6; i >= 0; i--) ledger.claim(i);
+ ledger.check();
+ assert(ledger.feesEarned - sum(ledger.paid) < 7n);
+ }
+ assert.equal(accepted, 90_000);
+ assert(buys > 30_000 && sells > 30_000, "exercise both sides rather than PRNG low-bit alternation");
+});
+
+test("3,600 isolated round trips cannot create SOL or tokens", () => {
+ let directions = 0;
+ const rng = random(91_337);
+ for (const offset of OFFSETS) for (const fee of FEES) for (let sample = 0; sample < 200; sample++) {
+ const state = initial(offset, fee);
+ const input = 10_000_000n + rng() * 1_001n;
+ const buy = quoteBuyOracle(state, input);
+ const sell = quoteSellOracle(nextState(state, buy), buy.amountOut);
+ assert(sell.amountOut <= input);
+ checkState(nextState(nextState(state, buy), sell));
+ directions++;
+
+ const acquired = nextState(state, buy);
+ const tokens = buy.amountOut / 3n;
+ const sold = quoteSellOracle(acquired, tokens);
+ const boughtBack = quoteBuyOracle(nextState(acquired, sold), sold.amountOut);
+ assert(boughtBack.amountOut <= tokens);
+ directions++;
+ }
+ assert.equal(directions, 3_600);
+});
+
+test("cumulative u128 fee entitlements are solvent and claim-order independent", () => {
+ const weights = [1, 2, 3, 4, 5, 6, 9979];
+ for (const earned of [0n, 1n, 6n, 9999n, 10000n, U64_MAX, U64_MAX + 1n, U128_MAX - 1n, U128_MAX]) {
+ const amounts = weights.map((weight) => entitlementOracle(earned, weight));
+ assert(sum(amounts) <= earned);
+ assert(earned - sum(amounts) < BigInt(weights.length));
+ assert.equal(sum([...amounts].reverse()), sum(amounts));
+ for (let i = 0; i < weights.length; i++) {
+ assert.equal(claimOracle(earned, weights[i], amounts[i]), 0n);
+ assert.throws(() => claimOracle(earned, weights[i], amounts[i] + 1n), /overpaid/);
+ }
+ }
+ assert.equal(entitlementOracle(U128_MAX, 10000), U128_MAX);
+ assert.throws(() => entitlementOracle(U128_MAX + 1n, 10000), /out of bounds/);
+});
+
+test("fee ceilings charge the smallest satisfying integer, even at u64 max", () => {
+ for (const fee of FEES) for (const amount of [0n, 1n, 2n, 33n, 34n, 99n, 100n, 101n, U64_MAX]) {
+ const charged = feeOracle(amount, fee);
+ assert(charged * 10000n >= amount * BigInt(fee));
+ if (charged > 0n) assert((charged - 1n) * 10000n < amount * BigInt(fee));
+ }
+});
+
+test("unsafe states, zero/dust output, slippage and integer boundaries reject", () => {
+ const state = initial();
+ for (const patch of [
+ { tokenInventory: 0n }, { tokenInventory: SUPPLY + 1n },
+ { virtualSol: MIN_VIRTUAL_SOL - 1n }, { virtualSol: MAX_VIRTUAL_SOL + 1n },
+ { realSolReserves: U64_MAX }, { feeBps: 1 }, { tokenInventory: SUPPLY - 1n },
+ ]) assert.throws(() => checkState({ ...state, ...patch }));
+ assert.throws(() => quoteBuyOracle(state, 0n), /zero input/);
+ assert.throws(() => quoteBuyOracle(state, 1n), /zero effective input/);
+ assert.throws(() => quoteBuyOracle({ ...state, feeBps: 0 }, U64_MAX), /overflow/);
+ assert.throws(() => quoteBuyOracle(state, 1000n, SUPPLY), /slippage/);
+ assert.throws(() => quoteSellOracle(state, 1n), /circulating/);
+ const buy = quoteBuyOracle(state, 1_000_000_000n);
+ const after = nextState(state, buy);
+ assert.throws(() => quoteSellOracle(after, buy.amountOut + 1n), /circulating/);
+ assert.throws(() => quoteSellOracle(after, 1n), /zero output/);
+ assert.throws(() => quoteSellOracle(after, buy.amountOut, U64_MAX), /slippage/);
+ assert.throws(() => quoteBuyOracle(state, Number.MAX_SAFE_INTEGER), /bigint/);
+});
+
+test("near-empty inventory and large real reserves never pay virtual SOL", () => {
+ for (const offset of OFFSETS) for (const fee of FEES) {
+ const state = initial(offset, fee);
+ // A large but u64-safe buy approaches the last atom without exhausting it.
+ const buy = quoteBuyOracle(state, (U64_MAX - offset) / 2n);
+ assert(buy.nextTokenInventory > 0n);
+ const after = nextState(state, buy);
+ const sell = quoteSellOracle(after, buy.amountOut);
+ assert(sell.amountOut + sell.fee <= after.realSolReserves);
+ assert.equal(sell.nextTokenInventory, SUPPLY);
+ assert(sell.nextRealSolReserves >= 0n);
+ }
+});
diff --git a/packages/solana-model/oracle.mjs b/packages/solana-model/oracle.mjs
new file mode 100644
index 00000000..075a0df1
--- /dev/null
+++ b/packages/solana-model/oracle.mjs
@@ -0,0 +1,125 @@
+// Deliberately independent of the program and SDK. Solve the invariant by
+// monotone search, instead of implementing their closed-form swap quotients.
+import assert from "node:assert/strict";
+
+export const SUPPLY = 1_000_000_000_000_000n;
+export const U64_MAX = (1n << 64n) - 1n;
+export const U128_MAX = (1n << 128n) - 1n;
+export const MIN_VIRTUAL_SOL = 1_000_000_000n;
+export const MAX_VIRTUAL_SOL = 1_000_000_000_000_000n;
+const BPS = 10_000n;
+
+function integer(value, name, max = U64_MAX) {
+ assert.equal(typeof value, "bigint", `${name} must be bigint`);
+ assert(value >= 0n && value <= max, `${name} out of bounds`);
+}
+
+// pred must be true for an initial interval starting at zero.
+function largestTrue(limit, pred) {
+ assert(pred(0n));
+ let low = 0n;
+ let high = limit + 1n;
+ while (high - low > 1n) {
+ const mid = (high + low) >> 1n;
+ if (pred(mid)) low = mid;
+ else high = mid;
+ }
+ return low;
+}
+
+export function feeOracle(amount, feeBps) {
+ integer(amount, "fee amount");
+ assert([0, 100, 300].includes(feeBps), "unsupported fee");
+ if (feeBps === 0 || amount === 0n) return 0n;
+ const target = amount * BigInt(feeBps);
+ // Find the largest integer strictly below the fee threshold, then add one.
+ return largestTrue(amount, (value) => value * BPS < target) + 1n;
+}
+
+export function checkState(state) {
+ const { tokenInventory: x, realSolReserves: r, virtualSol: v, feeBps } = state;
+ integer(x, "inventory");
+ integer(r, "reserve");
+ integer(v, "virtual reserve");
+ assert(x > 0n && x <= SUPPLY, "invalid inventory");
+ assert(v >= MIN_VIRTUAL_SOL && v <= MAX_VIRTUAL_SOL, "virtual reserve out of bounds");
+ assert(r + v <= U64_MAX, "effective reserve overflow");
+ assert([0, 100, 300].includes(feeBps), "unsupported fee");
+ assert(x * (r + v) >= SUPPLY * v, "insolvent state");
+}
+
+function constraints(input, minOutput) {
+ integer(input, "input");
+ integer(minOutput, "minimum output");
+ assert(input > 0n, "zero input");
+}
+
+export function quoteBuyOracle(state, grossLamports, minOutput = 1n) {
+ checkState(state);
+ constraints(grossLamports, minOutput);
+ const { tokenInventory: x, realSolReserves: r, virtualSol: v } = state;
+ const fee = feeOracle(grossLamports, state.feeBps);
+ const netInput = grossLamports - fee;
+ assert(netInput > 0n, "zero effective input");
+ assert(r + v + netInput <= U64_MAX, "effective reserve overflow");
+ const product = x * (r + v);
+ const amountOut = largestTrue(x, (out) => (x - out) * (r + v + netInput) >= product);
+ assert(amountOut > 0n && amountOut < x, "zero or exhausted output");
+ assert(amountOut >= minOutput, "slippage");
+ return {
+ grossInput: grossLamports,
+ netInput,
+ fee,
+ amountOut,
+ nextTokenInventory: x - amountOut,
+ nextRealSolReserves: r + netInput,
+ };
+}
+
+export function quoteSellOracle(state, tokenAtoms, minOutput = 1n) {
+ checkState(state);
+ constraints(tokenAtoms, minOutput);
+ const { tokenInventory: x, realSolReserves: r, virtualSol: v } = state;
+ assert(tokenAtoms <= SUPPLY - x, "input exceeds circulating supply");
+ const product = x * (r + v);
+ // Deliberately search beyond actual R. The invariant must imply solvency;
+ // clipping this limit to R would hide an incorrectly specified curve.
+ const grossSol = largestTrue(r + v, (out) => (x + tokenAtoms) * (r + v - out) >= product);
+ assert(grossSol <= r, "virtual reserve withdrawal");
+ const fee = feeOracle(grossSol, state.feeBps);
+ const amountOut = grossSol - fee;
+ assert(amountOut > 0n, "zero output");
+ assert(amountOut >= minOutput, "slippage");
+ return {
+ grossInput: tokenAtoms,
+ netInput: tokenAtoms,
+ fee,
+ amountOut,
+ nextTokenInventory: x + tokenAtoms,
+ nextRealSolReserves: r - grossSol,
+ };
+}
+
+export function nextState(state, quote) {
+ return {
+ ...state,
+ tokenInventory: quote.nextTokenInventory,
+ realSolReserves: quote.nextRealSolReserves,
+ };
+}
+
+export function entitlementOracle(cumulativeFees, weightBps) {
+ integer(cumulativeFees, "lifetime fees", U128_MAX);
+ assert(Number.isInteger(weightBps) && weightBps > 0 && weightBps <= 10_000);
+ const target = cumulativeFees * BigInt(weightBps);
+ // JS bigint is unbounded: this intentionally exercises values whose naive
+ // multiplication overflows the program's u128 representation.
+ return largestTrue(cumulativeFees, (value) => value * BPS <= target);
+}
+
+export function claimOracle(cumulativeFees, weightBps, paid) {
+ integer(paid, "paid", U128_MAX);
+ const entitlement = entitlementOracle(cumulativeFees, weightBps);
+ assert(paid <= entitlement, "overpaid recipient");
+ return entitlement - paid;
+}
diff --git a/packages/solana-model/package.json b/packages/solana-model/package.json
new file mode 100644
index 00000000..5057bf35
--- /dev/null
+++ b/packages/solana-model/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@openlaunch/solana-model",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node --test model.test.mjs",
+ "test:sdk": "node --experimental-strip-types --test differential.test.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/solana/.gitignore b/solana/.gitignore
new file mode 100644
index 00000000..5a25853a
--- /dev/null
+++ b/solana/.gitignore
@@ -0,0 +1,5 @@
+/target/
+/.tools/
+/.anchor/
+/test-ledger/
+*-keypair.json
diff --git a/solana/ABI.md b/solana/ABI.md
new file mode 100644
index 00000000..f4bd4ca1
--- /dev/null
+++ b/solana/ABI.md
@@ -0,0 +1,52 @@
+# Launch pool ABI v1
+
+Unreleased, no mainnet program ID. Anchor 0.31.1 Borsh encoding. All integers little endian. Instruction discriminator: first 8 bytes of SHA-256 `global:`; account discriminator: first 8 bytes of SHA-256 `account:`.
+
+Seeds: pool `["pool", creator(32), nonce(u64 LE)]`; mint `["mint", pool]`; token vault `["token", pool]`; reserve `["reserve", pool]`; fees `["fees", pool]`; temporary mint authority `["mint-authority", pool]`. Every PDA is scoped to the configured program ID.
+
+## Instructions
+
+Accounts in exact order, `w` writable, `s` signer. System is `11111111111111111111111111111111`; token is legacy SPL only. `programData` must be linked by this program executable.
+
+- `prepare_launch(args: PrepareArgs)`: creator(s), payer(ws), pool(w), systemProgram. Args: nonce u64; virtualSol u64; feeBps u16; name string; symbol string; uri string; contentHash [u8;32]; recipients Vec. RecipientInput: address Pubkey, weightBps u16.
+- `activate_launch()`: creator(ws), pool(w), mint(w), tokenVault(w), reserve(w), fees(w), mintAuthority, executable, programData, tokenProgram, systemProgram, rent. Creator pays activation rent.
+- `cancel_preparation()`: creator(s), pool(w). Retains full preparation as permanent cancelled tombstone. No refund because preparation has no auxiliary accounts.
+- `buy_exact_in(amountIn:u64, minimumOut:u64, expirySlot:u64)`: trader(ws), pool(w), mint, tokenVault(w), reserve(w), fees(w), traderToken(w), tokenProgram, systemProgram. Trader token account must exist and have correct mint/owner. SDK may prepend ATA creation.
+- `sell_exact_in(amountIn:u64, minimumOut:u64, expirySlot:u64)`: same account order as buy. Shared ABI retains systemProgram.
+- `claim_fees(recipientIndex:u8)`: pool(w), fees(w), recipient(w). Permissionless; recipient must exactly match immutable entry.
+
+## Accounts
+
+`Pool` fields after 8-byte discriminator:
+
+```
+version u8 (=1)
+status u8 (0 Prepared, 1 Active, 2 Cancelled)
+bump u8
+nonce u64
+creator Pubkey
+rentPayer Pubkey
+mint Pubkey
+virtualSol u64
+feeBps u16
+recipientCount u8
+recipients [Recipient;7]
+tokenReserve u64
+solReserve u64
+feesEarned u128
+sequence u64
+name string
+symbol string
+uri string
+contentHash [u8;32]
+```
+
+`Recipient`: address Pubkey, weightBps u16, paid u128. Unused entries all zero.
+
+`SolVault`: pool Pubkey, kind u8 (0 reserve, 1 fees), rentFloor u64 after 8-byte discriminator. Size 49 bytes.
+
+Maximum UTF-8 bytes: name 32, symbol 10, URI 200. Original supply 1,000,000,000,000,000 atoms; decimals 6. Virtual offset 1,000,000,000 through 1,000,000,000,000,000 lamports inclusive. Mechanical limits, not endorsed valuations. Fees 0/100/300; nonzero fees require 1..7 unique nonzero recipients with weights totaling 10000; zero fees require no recipients.
+
+Checked arithmetic: buy `R+V+net` fits u64; sell `R+V` fits u64; cumulative earned/paid u128. Expiry must be current slot or later. Reject zero input/output, nonpositive net, slippage, expired execution, inventory excess, and insufficient real SOL.
+
+Events use Anchor `event:` discriminator. `Prepared`: pool, creator, nonce. `Activated`: pool, mint, supply. `Cancelled`: pool. `Swap`: pool Pubkey, trader Pubkey, sequence u64, isBuy bool, amountIn u64, amountOut u64, fee u64, tokenReserve u64, solReserve u64, slot u64. `FeesClaimed`: pool Pubkey, recipient Pubkey, amount u64, cumulativePaid u128. Verify successful transaction origin and reconcile account state; events alone are not evidence of origin.
diff --git a/solana/Anchor.toml b/solana/Anchor.toml
new file mode 100644
index 00000000..a4714985
--- /dev/null
+++ b/solana/Anchor.toml
@@ -0,0 +1,17 @@
+[toolchain]
+anchor_version = "0.31.1"
+# Use scripts/check-sbf.sh for the independently pinned modern SBPFv3 compiler.
+# The historical Anchor 0.31 recommended Agave 2.1 is not a release requirement.
+
+[features]
+resolution = true
+skip-lint = false
+
+# Compilation/test fixture only. Kevin must bind the reviewed source and SDK to
+# a real deployment ID before reproducible build and immutable release review.
+[programs.localnet]
+launch_pool = "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr"
+
+[provider]
+cluster = "Localnet"
+wallet = "~/.config/solana/id.json"
diff --git a/solana/BUILD.md b/solana/BUILD.md
new file mode 100644
index 00000000..03824209
--- /dev/null
+++ b/solana/BUILD.md
@@ -0,0 +1,74 @@
+# Reproducible build and test handoff
+
+No deployment is performed by these commands. The source contains an undeployed compilation fixture ID. Never expose this fixture as a real Solana launch option. Kevin must bind the reviewed real program ID into source, app configuration and the release manifest before the final build and audit comparison.
+
+## Host checks
+
+From `solana/`:
+
+```sh
+cargo fmt --all --check
+cargo test -p launch-pool --locked
+```
+
+Commit `Cargo.lock`. Host tests exercise curve boundaries, 108,000 sampled transitions, an independently implemented model's vectors, SDK/Rust byte compatibility, generated Anchor dispatch/account constraints, cancellations, fee claims, and buys/sells using the real legacy SPL TransferChecked processor through a limited syscall harness. The harness is explicitly not a validator: it cannot establish SBF stack/compute limits, account-creation prefunding semantics, runtime rent rules, or transaction rollback.
+
+## SBF build, no cluster access
+
+The pinned Anchor crates are `0.31.1`. Their historical Agave `2.1` recommendation is not a production-toolchain recommendation in October 2026. Current Anza documentation says deployment of older SBPF formats will be blocked by SIMD-500 activation. Build explicitly for SBPFv3, not the compiler's v0 default. This program's Solana SDK resolves to 2.3 through the lockfile. Verify all toolchain and cluster-feature compatibility before release. [Anza compiler documentation](https://github.com/anza-xyz/cargo-build-sbf), [4.2.0 release](https://github.com/anza-xyz/cargo-build-sbf/releases/tag/cargo-build-sbf%40v4.2.0)
+
+Install the official build-only command in a repository-local ignored directory:
+
+```sh
+cargo install cargo-build-sbf --version 4.2.0 --locked --root .tools
+bash scripts/check-sbf.sh
+```
+
+Windows PowerShell equivalent:
+
+```powershell
+cargo install cargo-build-sbf --version 4.2.0 --locked --root .tools
+.tools/bin/cargo-build-sbf.exe --tools-version v1.56 --arch v3 --manifest-path programs/launch_pool/Cargo.toml -- --locked
+```
+
+The compiler downloads platform-tools `v1.56` (Rust 1.89) from Anza's releases. Record artifact digests in the final build manifest and independently check the downloaded toolchain provenance. It may generate an ignored local program-keypair file as a build artifact. Do not use that generated fixture for deployment and do not commit keypairs. No script reads wallet configuration, signs transactions, chooses an RPC, or runs `deploy`/`set-upgrade-authority`.
+
+Expected binary: `solana/target/deploy/launch_pool.so`. Collect its SHA-256 and complete compiler log, reject stack-frame/undefined-symbol diagnostics, then reproduce the build on a separate clean machine/container. Source changes, including changing the program ID, invalidate earlier bytecode evidence. Host tests passing is not evidence that this SBF build passes.
+
+Local verification on 10 October 2026: the pinned Windows build completed for `--arch v3`, producing a 373,464-byte fixture binary with SHA-256 `a6159ed6a87ee96257436363b1391de18e88fb8e83195306b8182fe1339fd832`. No stack-frame or undefined-symbol diagnostic was emitted. This is one local build, not independently reproduced production bytecode and not a deployment. The compiler notes that keeping both `cdylib` and `lib` crate outputs prevents its LTO optimization; both are retained so host/library tests use the same crate. Reassess size/compute using the validator tests, not assumed LTO benefits.
+
+## Offline SBF runtime evidence
+
+After building the ELF, run:
+
+```sh
+cargo test --workspace --features svm-tests --locked
+cargo clippy --workspace --all-targets --features svm-tests --locked -- -D warnings
+```
+
+The separate `runtime-tests` crate pins LiteSVM `0.13.1`, with Agave 4.0 dependencies committed in the lockfile. It loads the actual SBPFv3 ELF with the upgradeable loader and linked immutable ProgramData, then runs real System and legacy SPL Token instructions. Ephemeral fixture identities exist only in test memory; there is no RPC, real wallet, persisted signer or deployment.
+
+On 10 October 2026, all 21 host tests and all 9 actual-SBF runtime tests passed on Windows. Runtime coverage includes:
+
+- Complete prepare/activate/buy/sell/claim lifecycle, full supply in custody, mint/freeze authority absence and failed extra mint/freeze attempts.
+- Prefunding each of pool, mint, token vault, reserve and fee PDA before account creation.
+- Rejected cancellation/activation/initialization replay, retained upgrade authority and unrelated ProgramData.
+- Actual transaction rollback after successful activation CPIs or a buy when a subsequent instruction fails.
+- Maximum metadata and seven recipients. The maximum preparation packet measured 792 bytes against the 1,232-byte limit; every submitted test transaction checks that limit and runs under runtime compute metering.
+- Wrong custody/program/mint accounts, expired execution, donations, holder burns and recipient rent failure isolation.
+
+Observed compute varies with randomly generated PDA bumps: sampled preparation 23k–48k, activation 44k–61k, buy 25k–33k, sell 21k–30k and claim 9,641 units. These are measured fixture ranges, not worst-case proofs or promised network costs. Runtime tests load the compiled binary from `target/deploy`; rebuild after any source change to avoid testing stale bytecode.
+
+## Remaining validator/release rehearsal
+
+Use a compatible SBPFv3 test runtime (Anza documents Solana/Agave 4.0+ for v3 tooling) with a disposable fixture program. Do not reuse the historical Agave 2.1 validator. Pin and record the exact validator version and active feature set in test evidence. [Anchor CLI compatibility](https://www.anchor-lang.com/docs/references/cli)
+
+- Execute prepare, activate, buy, sell, claim and cancel through real transactions, including maximum metadata and seven fee recipients. Measure transaction bytes, compute, stack and heap use.
+- Prove activation fails with retained upgrade authority and unrelated ProgramData, succeeds only for its own immutable deployment, atomically mints the entire supply into custody and clears mint authority.
+- Prefund every PDA independently before creation: pool, mint, token vault, SOL reserve, fee vault. Preparation/activation must succeed without accepting preowned/reinitialized accounts.
+- Exercise cancellation followed by preparation/activation replay, wrong owners/mints/program IDs/signers/seeds, frozen/delegated accounts, duplicate mutable accounts and counterfeit events.
+- Verify transaction rollback after a successful first CPI and deliberately failing later CPI, insufficient fee/rent, expired blockhash, slippage race, failed simulation and multiple swaps in one transaction.
+- Verify ordinary SPL transfers/burns/donations, fee claims below recipient rent thresholds, off-curve smart wallets and one unusable fee recipient not blocking others. No transfer to a recipient may consume trading reserves.
+- Compare final vault balances, fee liabilities, pool sequence, SDK quotes and indexed successful trades. Run finalized-history recovery and independent RPC reconciliation.
+
+The offline runtime suite covers the listed subset, not a full networked-validator or production-wallet rehearsal. Remaining adversarial combinations, long randomized runtime campaigns, final-cluster feature compatibility, rent/compute costs, full indexing, real-wallet failure handling and deployment-bytecode reproduction must be evidenced before release. CI should fail on build/test errors; it must never deploy or revoke authority. Independent protocol security and economic review are still required after automated checks.
diff --git a/solana/Cargo.lock b/solana/Cargo.lock
new file mode 100644
index 00000000..80418b99
--- /dev/null
+++ b/solana/Cargo.lock
@@ -0,0 +1,5573 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common 0.1.7",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "aes-gcm-siv"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "polyval",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "agave-bls12-381"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "210b1ef312273aa81ccb4c52687d96e3cf07621f3619a7998be20eb9741b08e3"
+dependencies = [
+ "blst",
+ "blstrs",
+ "bytemuck",
+ "bytemuck_derive",
+ "group",
+ "pairing",
+]
+
+[[package]]
+name = "agave-feature-set"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dde74a2d1f2f99a3ea59938d1533c7973c344e47d24c1b645ee81e958c54226a"
+dependencies = [
+ "ahash",
+ "solana-epoch-schedule 3.0.0",
+ "solana-hash 4.2.0",
+ "solana-keypair",
+ "solana-pubkey 4.1.0",
+ "solana-sha256-hasher 3.1.0",
+ "solana-svm-feature-set",
+]
+
+[[package]]
+name = "agave-reserved-account-keys"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "798e559c514af005950ea81586a3856f9297ecb80a7359057c19bf6717f5f537"
+dependencies = [
+ "agave-feature-set",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+]
+
+[[package]]
+name = "agave-syscalls"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84debd4abe0cbab5a6aac2ee50e3969ef0e0961f7dff7e8f96bda0be7998bca2"
+dependencies = [
+ "agave-bls12-381",
+ "bincode",
+ "libsecp256k1",
+ "num-traits",
+ "solana-account 3.4.0",
+ "solana-account-info 3.1.1",
+ "solana-big-mod-exp 3.0.0",
+ "solana-blake3-hasher 3.1.0",
+ "solana-bn254",
+ "solana-clock 3.0.1",
+ "solana-cpi 3.1.0",
+ "solana-curve25519 4.0.1",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-keccak-hasher 3.1.0",
+ "solana-loader-v3-interface 6.1.0",
+ "solana-poseidon",
+ "solana-program-entrypoint 3.1.1",
+ "solana-program-runtime",
+ "solana-pubkey 4.1.0",
+ "solana-sbpf",
+ "solana-sdk-ids 3.1.0",
+ "solana-secp256k1-recover 3.3.0",
+ "solana-sha256-hasher 3.1.0",
+ "solana-stable-layout 3.0.1",
+ "solana-stake-interface 2.0.2",
+ "solana-svm-callback",
+ "solana-svm-feature-set",
+ "solana-svm-log-collector",
+ "solana-svm-measure",
+ "solana-svm-timings",
+ "solana-svm-type-overrides",
+ "solana-sysvar 3.1.1",
+ "solana-sysvar-id 3.1.0",
+ "solana-transaction-context",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anchor-attribute-access-control"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7122e95b3e5256a548bd7f6642704c052db3b14d9d70a65d80358d16e29c98f"
+dependencies = [
+ "anchor-syn",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-attribute-account"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9a9d17a623263f338511b4a849c4accd99be03b84a8f7b9f64d0988a29771c9"
+dependencies = [
+ "anchor-syn",
+ "bs58",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-attribute-constant"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3c34dc7f6a6d39d64102564e2ca4a747b6b1e97e467fb0763a0c2b94a50f952"
+dependencies = [
+ "anchor-syn",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-attribute-error"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a21a0775db8a924515fc252a21c16e8f320af6da1e535d024a7794d813d267ad"
+dependencies = [
+ "anchor-syn",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-attribute-event"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fb2e11a313bd117800be14f7ad786c03a3f89faf3159cdc2e5326bcc6b5ea04"
+dependencies = [
+ "anchor-syn",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-attribute-program"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "801aa0c02c94cd3456ed336e1a178d3311f0822c5c4b0f1eb59be4c686accbee"
+dependencies = [
+ "anchor-lang-idl",
+ "anchor-syn",
+ "anyhow",
+ "bs58",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "serde_json",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-derive-accounts"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933704da621849cd37cd1862fbaa972956328fbafc8edf27d8cc46af6cc1bb"
+dependencies = [
+ "anchor-syn",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-derive-serde"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1160994c286598c7bb4e35ad7884296a483b12c08e1ccd5a8ba36cffb5857668"
+dependencies = [
+ "anchor-syn",
+ "borsh-derive-internal",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-derive-space"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fd8555bd071a4f1f43656f27fdaf576532278c4562083676f819529e96ae5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "anchor-lang"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6bab117055905e930f762c196e08f861f8dfe7241b92cee46677a3b15561a0a"
+dependencies = [
+ "anchor-attribute-access-control",
+ "anchor-attribute-account",
+ "anchor-attribute-constant",
+ "anchor-attribute-error",
+ "anchor-attribute-event",
+ "anchor-attribute-program",
+ "anchor-derive-accounts",
+ "anchor-derive-serde",
+ "anchor-derive-space",
+ "anchor-lang-idl",
+ "base64 0.21.7",
+ "bincode",
+ "borsh 0.10.4",
+ "bytemuck",
+ "solana-program",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "anchor-lang-idl"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47914b4290ae2bdf4ec203aa821e6eba86d7c78ef497918938038dcc6919f953"
+dependencies = [
+ "anchor-lang-idl-spec",
+ "anyhow",
+ "heck",
+ "regex",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "anchor-lang-idl-spec"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bdf143115440fe621bdac3a29a1f7472e09f6cd82b2aa569429a0c13f103838"
+dependencies = [
+ "anyhow",
+ "serde",
+]
+
+[[package]]
+name = "anchor-spl"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c08cb5d762c0694f74bd02c9a5b04ea53cefc496e2c27b3234acffca5cd076b"
+dependencies = [
+ "anchor-lang",
+ "spl-pod",
+ "spl-token",
+ "spl-token-2022",
+ "spl-token-group-interface",
+ "spl-token-metadata-interface",
+]
+
+[[package]]
+name = "anchor-syn"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99f085a93ab107b9ab2ec70be125b17d2c64c05eebf4da52281a16613a588613"
+dependencies = [
+ "anyhow",
+ "bs58",
+ "cargo_toml",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "syn 1.0.109",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ansi_term"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "ark-bn254"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f"
+dependencies = [
+ "ark-ec 0.4.2",
+ "ark-ff 0.4.2",
+ "ark-std 0.4.0",
+]
+
+[[package]]
+name = "ark-bn254"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc"
+dependencies = [
+ "ark-ec 0.5.0",
+ "ark-ff 0.5.0",
+ "ark-std 0.5.0",
+]
+
+[[package]]
+name = "ark-ec"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba"
+dependencies = [
+ "ark-ff 0.4.2",
+ "ark-poly 0.4.2",
+ "ark-serialize 0.4.2",
+ "ark-std 0.4.0",
+ "derivative",
+ "hashbrown 0.13.2",
+ "itertools 0.10.5",
+ "num-traits",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ec"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce"
+dependencies = [
+ "ahash",
+ "ark-ff 0.5.0",
+ "ark-poly 0.5.0",
+ "ark-serialize 0.5.0",
+ "ark-std 0.5.0",
+ "educe",
+ "fnv",
+ "hashbrown 0.15.5",
+ "itertools 0.13.0",
+ "num-bigint 0.4.8",
+ "num-integer",
+ "num-traits",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ff"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba"
+dependencies = [
+ "ark-ff-asm 0.4.2",
+ "ark-ff-macros 0.4.2",
+ "ark-serialize 0.4.2",
+ "ark-std 0.4.0",
+ "derivative",
+ "digest 0.10.7",
+ "itertools 0.10.5",
+ "num-bigint 0.4.8",
+ "num-traits",
+ "paste",
+ "rustc_version",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ff"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70"
+dependencies = [
+ "ark-ff-asm 0.5.0",
+ "ark-ff-macros 0.5.0",
+ "ark-serialize 0.5.0",
+ "ark-std 0.5.0",
+ "arrayvec",
+ "digest 0.10.7",
+ "educe",
+ "itertools 0.13.0",
+ "num-bigint 0.4.8",
+ "num-traits",
+ "paste",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ff-asm"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348"
+dependencies = [
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "ark-ff-asm"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ark-ff-macros"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "ark-ff-macros"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ark-poly"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf"
+dependencies = [
+ "ark-ff 0.4.2",
+ "ark-serialize 0.4.2",
+ "ark-std 0.4.0",
+ "derivative",
+ "hashbrown 0.13.2",
+]
+
+[[package]]
+name = "ark-poly"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27"
+dependencies = [
+ "ahash",
+ "ark-ff 0.5.0",
+ "ark-serialize 0.5.0",
+ "ark-std 0.5.0",
+ "educe",
+ "fnv",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "ark-serialize"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
+dependencies = [
+ "ark-serialize-derive 0.4.2",
+ "ark-std 0.4.0",
+ "digest 0.10.7",
+ "num-bigint 0.4.8",
+]
+
+[[package]]
+name = "ark-serialize"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7"
+dependencies = [
+ "ark-serialize-derive 0.5.0",
+ "ark-std 0.5.0",
+ "arrayvec",
+ "digest 0.10.7",
+ "num-bigint 0.4.8",
+]
+
+[[package]]
+name = "ark-serialize-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "ark-serialize-derive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185"
+dependencies = [
+ "num-traits",
+ "rand 0.8.8",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a"
+dependencies = [
+ "num-traits",
+ "rand 0.8.8",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "ascii"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "bitvec"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[package]]
+name = "blake3"
+version = "1.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
+dependencies = [
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "blst"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe"
+dependencies = [
+ "cc",
+ "glob",
+ "threadpool",
+ "zeroize",
+]
+
+[[package]]
+name = "blstrs"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a8a8ed6fefbeef4a8c7b460e4110e12c5e22a5b7cf32621aae6ad650c4dcf29"
+dependencies = [
+ "blst",
+ "byte-slice-cast",
+ "ff",
+ "group",
+ "pairing",
+ "rand_core 0.6.4",
+ "serde",
+ "subtle",
+]
+
+[[package]]
+name = "borsh"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee"
+dependencies = [
+ "borsh-derive 0.10.4",
+ "hashbrown 0.13.2",
+]
+
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive 1.8.1",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89"
+dependencies = [
+ "borsh-derive-internal",
+ "borsh-schema-derive-internal",
+ "proc-macro-crate 0.1.5",
+ "proc-macro2",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "borsh-derive-internal"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "borsh-schema-derive-internal"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bv"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340"
+dependencies = [
+ "feature-probe",
+ "serde",
+]
+
+[[package]]
+name = "byte-slice-cast"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cargo_toml"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a98356df42a2eb1bd8f1793ae4ee4de48e384dd974ce5eac8eee802edb7492be"
+dependencies = [
+ "serde",
+ "toml 0.8.23",
+]
+
+[[package]]
+name = "cc"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "cfg_eval"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common 0.1.7",
+ "inout",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "combine"
+version = "3.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680"
+dependencies = [
+ "ascii",
+ "byteorder",
+ "either",
+ "memchr",
+ "unreachable",
+]
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "console_log"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e89f72f65e8501878b8a004d5a1afb780987e2ce2b4532c562e367a72c57499f"
+dependencies = [
+ "log",
+ "web-sys",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rand_core 0.6.4",
+ "rustc_version",
+ "serde",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
+dependencies = [
+ "darling_core 0.24.1",
+ "darling_macro 0.24.1",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
+dependencies = [
+ "darling_core 0.24.1",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "derivation-path"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0"
+
+[[package]]
+name = "derivative"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "digest"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "const-oid",
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "eager"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe71d579d1812060163dff96056261deb5bf6729b100fa2e36a68b9649ba3d3"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core 0.6.4",
+ "serde",
+ "sha2 0.10.9",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "educe"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417"
+dependencies = [
+ "enum-ordinalize",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "enum-iterator"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016"
+dependencies = [
+ "enum-iterator-derive",
+]
+
+[[package]]
+name = "enum-iterator-derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "enum-ordinalize"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677"
+dependencies = [
+ "enum-ordinalize-derive",
+]
+
+[[package]]
+name = "enum-ordinalize-derive"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "feature-probe"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "bitvec",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "five8"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75b8549488b4715defcb0d8a8a1c1c76a80661b5fa106b4ca0e7fce59d7d875"
+dependencies = [
+ "five8_core 0.1.2",
+]
+
+[[package]]
+name = "five8"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f76610e969fa1784327ded240f1e28a3fd9520c9cec93b636fcf62dd37f772"
+dependencies = [
+ "five8_core 0.1.2",
+]
+
+[[package]]
+name = "five8_const"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26dec3da8bc3ef08f2c04f61eab298c3ab334523e55f076354d6d6f613799a7b"
+dependencies = [
+ "five8_core 0.1.2",
+]
+
+[[package]]
+name = "five8_const"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a0f1728185f277989ca573a402716ae0beaaea3f76a8ff87ef9dd8fb19436c5"
+dependencies = [
+ "five8_core 0.1.2",
+]
+
+[[package]]
+name = "five8_core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5"
+
+[[package]]
+name = "five8_core"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "059c31d7d36c43fe39d89e55711858b4da8be7eb6dabac23c7289b1a19489406"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand 0.8.8",
+ "rand_core 0.6.4",
+ "rand_xorshift",
+ "subtle",
+]
+
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2 0.10.9",
+ "signature",
+]
+
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "launch-pool"
+version = "0.1.0"
+dependencies = [
+ "anchor-lang",
+ "anchor-spl",
+ "bincode",
+ "serde_json",
+]
+
+[[package]]
+name = "launch-pool-svm"
+version = "0.1.0"
+dependencies = [
+ "anchor-lang",
+ "anchor-spl",
+ "bincode",
+ "five8_core 0.1.2",
+ "launch-pool",
+ "litesvm",
+ "solana-account 3.4.0",
+ "solana-address 2.4.0",
+ "solana-clock 3.0.1",
+ "solana-instruction 3.2.0",
+ "solana-keypair",
+ "solana-message 3.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-signer 3.0.0",
+ "solana-transaction",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "libsecp256k1"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73"
+dependencies = [
+ "arrayref",
+ "base64 0.12.3",
+ "digest 0.9.0",
+ "libsecp256k1-core",
+ "libsecp256k1-gen-ecmult",
+ "libsecp256k1-gen-genmult",
+ "rand 0.7.3",
+ "serde",
+ "sha2 0.9.9",
+]
+
+[[package]]
+name = "libsecp256k1-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80"
+dependencies = [
+ "crunchy",
+ "digest 0.9.0",
+ "subtle",
+]
+
+[[package]]
+name = "libsecp256k1-gen-ecmult"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3"
+dependencies = [
+ "libsecp256k1-core",
+]
+
+[[package]]
+name = "libsecp256k1-gen-genmult"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d"
+dependencies = [
+ "libsecp256k1-core",
+]
+
+[[package]]
+name = "light-poseidon"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c9a85a9752c549ceb7578064b4ed891179d20acd85f27318573b64d2d7ee7ee"
+dependencies = [
+ "ark-bn254 0.4.0",
+ "ark-ff 0.4.2",
+ "num-bigint 0.4.8",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "light-poseidon"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29d3992daf44efce5f36b8e61dc5373ee5ff7485cc59d02f5d8afac8a57067eb"
+dependencies = [
+ "ark-bn254 0.5.0",
+ "ark-ff 0.5.0",
+ "num-bigint 0.4.8",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "litesvm"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1e00083aad2a7aa9d6900454604f7776da40be57304e5119f09222a1e9b105a"
+dependencies = [
+ "agave-feature-set",
+ "agave-reserved-account-keys",
+ "agave-syscalls",
+ "ansi_term",
+ "bincode",
+ "indexmap",
+ "itertools 0.14.0",
+ "log",
+ "serde",
+ "solana-account 3.4.0",
+ "solana-address 2.4.0",
+ "solana-address-lookup-table-interface 3.0.1",
+ "solana-bpf-loader-program",
+ "solana-builtins",
+ "solana-clock 3.0.1",
+ "solana-compute-budget",
+ "solana-compute-budget-instruction",
+ "solana-epoch-rewards 3.0.1",
+ "solana-epoch-schedule 3.0.0",
+ "solana-feature-gate-interface 3.1.0",
+ "solana-fee",
+ "solana-fee-structure",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-instruction-error",
+ "solana-instructions-sysvar 3.0.0",
+ "solana-keypair",
+ "solana-last-restart-slot 3.0.1",
+ "solana-loader-v3-interface 6.1.0",
+ "solana-loader-v4-interface 3.1.0",
+ "solana-loader-v4-program",
+ "solana-message 3.1.0",
+ "solana-native-token 3.0.0",
+ "solana-nonce 3.1.0",
+ "solana-nonce-account",
+ "solana-precompile-error",
+ "solana-program-error 3.0.1",
+ "solana-program-runtime",
+ "solana-rent 3.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-sha256-hasher 3.1.0",
+ "solana-signature 3.3.0",
+ "solana-signer 3.0.0",
+ "solana-slot-hashes 3.0.1",
+ "solana-slot-history 3.0.1",
+ "solana-stake-interface 2.0.2",
+ "solana-svm-callback",
+ "solana-svm-log-collector",
+ "solana-svm-timings",
+ "solana-svm-transaction",
+ "solana-system-interface 3.1.0",
+ "solana-system-program",
+ "solana-sysvar 3.1.1",
+ "solana-sysvar-id 3.1.0",
+ "solana-transaction",
+ "solana-transaction-context",
+ "solana-transaction-error 3.1.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "merlin"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
+dependencies = [
+ "byteorder",
+ "keccak",
+ "rand_core 0.6.4",
+ "zeroize",
+]
+
+[[package]]
+name = "num"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
+dependencies = [
+ "num-bigint 0.2.6",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95"
+dependencies = [
+ "autocfg",
+ "num-traits",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef"
+dependencies = [
+ "autocfg",
+ "num-bigint 0.2.6",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "pairing"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f"
+dependencies = [
+ "group",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
+[[package]]
+name = "pbkdf2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "percentage"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937"
+dependencies = [
+ "num",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785"
+dependencies = [
+ "toml 0.5.11",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.17+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "qstring"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "qualifier_attr"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom 0.1.16",
+ "libc",
+ "rand_chacha 0.2.2",
+ "rand_core 0.5.1",
+ "rand_hc",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-big-array"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
+dependencies = [
+ "serde_core",
+ "serde_with_macros",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
+dependencies = [
+ "darling 0.24.1",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
+dependencies = [
+ "block-buffer 0.9.0",
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.9.0",
+ "opaque-debug",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2-const-stable"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9"
+
+[[package]]
+name = "sha3"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+dependencies = [
+ "digest 0.10.7",
+ "keccak",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "solana-account"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f949fe4edaeaea78c844023bfc1c898e0b1f5a100f8a8d2d0f85d0a7b090258"
+dependencies = [
+ "solana-account-info 2.3.0",
+ "solana-clock 2.2.3",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+]
+
+[[package]]
+name = "solana-account"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efc0ed36decb689413b9da5d57f2be49eea5bebb3cf7897015167b0c4336e731"
+dependencies = [
+ "bincode",
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-account-info 3.1.1",
+ "solana-clock 3.0.1",
+ "solana-instruction-error",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-sysvar 3.1.1",
+]
+
+[[package]]
+name = "solana-account-info"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da"
+dependencies = [
+ "bincode",
+ "serde",
+ "solana-program-error 2.2.2",
+ "solana-program-memory 2.3.1",
+ "solana-pubkey 2.4.0",
+]
+
+[[package]]
+name = "solana-account-info"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95"
+dependencies = [
+ "solana-address 2.4.0",
+ "solana-program-error 3.0.1",
+ "solana-program-memory 3.1.0",
+]
+
+[[package]]
+name = "solana-address"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2ecac8e1b7f74c2baa9e774c42817e3e75b20787134b76cc4d45e8a604488f5"
+dependencies = [
+ "solana-address 2.4.0",
+]
+
+[[package]]
+name = "solana-address"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f67735365edc7fb19ed74ec950597107c8ee9cbfebac57b8868b3e78fb6df16"
+dependencies = [
+ "bytemuck",
+ "bytemuck_derive",
+ "curve25519-dalek",
+ "five8 1.0.0",
+ "five8_const 1.0.0",
+ "serde",
+ "serde_derive",
+ "sha2-const-stable",
+ "solana-atomic-u64 3.0.1",
+ "solana-define-syscall 5.2.0",
+ "solana-program-error 3.0.1",
+ "solana-sanitize 3.0.1",
+ "solana-sha256-hasher 3.1.0",
+]
+
+[[package]]
+name = "solana-address-lookup-table-interface"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1673f67efe870b64a65cb39e6194be5b26527691ce5922909939961a6e6b395"
+dependencies = [
+ "bincode",
+ "bytemuck",
+ "serde",
+ "serde_derive",
+ "solana-clock 2.2.3",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-slot-hashes 2.2.1",
+]
+
+[[package]]
+name = "solana-address-lookup-table-interface"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e8df0b083c10ce32490410f3795016b1b5d9b4d094658c0a5e496753645b7cd"
+dependencies = [
+ "bincode",
+ "bytemuck",
+ "serde",
+ "serde_derive",
+ "solana-clock 3.0.1",
+ "solana-instruction 3.2.0",
+ "solana-instruction-error",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-slot-hashes 3.0.1",
+]
+
+[[package]]
+name = "solana-atomic-u64"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52e52720efe60465b052b9e7445a01c17550666beec855cce66f44766697bc2"
+dependencies = [
+ "parking_lot",
+]
+
+[[package]]
+name = "solana-atomic-u64"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "085db4906d89324cef2a30840d59eaecf3d4231c560ec7c9f6614a93c652f501"
+dependencies = [
+ "parking_lot",
+]
+
+[[package]]
+name = "solana-big-mod-exp"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75db7f2bbac3e62cfd139065d15bcda9e2428883ba61fc8d27ccb251081e7567"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-traits",
+ "solana-define-syscall 2.3.0",
+]
+
+[[package]]
+name = "solana-big-mod-exp"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30c80fb6d791b3925d5ec4bf23a7c169ef5090c013059ec3ed7d0b2c04efa085"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-traits",
+ "solana-define-syscall 3.0.0",
+]
+
+[[package]]
+name = "solana-bincode"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc"
+dependencies = [
+ "bincode",
+ "serde",
+ "solana-instruction 2.3.3",
+]
+
+[[package]]
+name = "solana-bincode"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "278a1a5bad62cd9da89ac8d4b7ec444e83caa8ae96aa656dfc27684b28d49a5d"
+dependencies = [
+ "bincode",
+ "serde_core",
+ "solana-instruction-error",
+]
+
+[[package]]
+name = "solana-blake3-hasher"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a0801e25a1b31a14494fc80882a036be0ffd290efc4c2d640bfcca120a4672"
+dependencies = [
+ "blake3",
+ "solana-define-syscall 2.3.0",
+ "solana-hash 2.3.0",
+ "solana-sanitize 2.2.1",
+]
+
+[[package]]
+name = "solana-blake3-hasher"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7116e1d942a2432ca3f514625104757ab8a56233787e95144c93950029e31176"
+dependencies = [
+ "blake3",
+ "solana-define-syscall 4.0.1",
+ "solana-hash 4.2.0",
+]
+
+[[package]]
+name = "solana-bls-signatures"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a3d8a6e1a009bddbdbfe13ee6ff206c16afa9f8fae7d04612d779ac2254ad5f"
+dependencies = [
+ "base64 0.22.1",
+ "blst",
+ "blstrs",
+ "cfg_eval",
+ "ff",
+ "group",
+ "pairing",
+ "rand 0.8.8",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-bn254"
+version = "3.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62ff13a8867fcc7b0f1114764e1bf6191b4551dcaf93729ddc676cd4ec6abc9f"
+dependencies = [
+ "ark-bn254 0.5.0",
+ "ark-ec 0.5.0",
+ "ark-ff 0.5.0",
+ "ark-serialize 0.5.0",
+ "bytemuck",
+ "solana-define-syscall 5.2.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-borsh"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "718333bcd0a1a7aed6655aa66bef8d7fb047944922b2d3a18f49cbc13e73d004"
+dependencies = [
+ "borsh 0.10.4",
+ "borsh 1.8.1",
+]
+
+[[package]]
+name = "solana-borsh"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c04abbae16f57178a163125805637b8a076175bb5c0002fb04f4792bea901cf7"
+dependencies = [
+ "borsh 1.8.1",
+]
+
+[[package]]
+name = "solana-bpf-loader-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219bfba64973ac9e64aa181f03fd56ac319e2d50d8a23d16c54bbd7fa9807a47"
+dependencies = [
+ "agave-syscalls",
+ "bincode",
+ "qualifier_attr",
+ "solana-account 3.4.0",
+ "solana-bincode 3.1.0",
+ "solana-clock 3.0.1",
+ "solana-instruction 3.2.0",
+ "solana-loader-v3-interface 6.1.0",
+ "solana-loader-v4-interface 3.1.0",
+ "solana-packet",
+ "solana-program-entrypoint 3.1.1",
+ "solana-program-runtime",
+ "solana-pubkey 4.1.0",
+ "solana-sbpf",
+ "solana-sdk-ids 3.1.0",
+ "solana-svm-feature-set",
+ "solana-svm-log-collector",
+ "solana-svm-measure",
+ "solana-svm-type-overrides",
+ "solana-system-interface 3.1.0",
+ "solana-transaction-context",
+]
+
+[[package]]
+name = "solana-builtins"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dda9d147935c741533496edf72c5b712885d4793a0bca13a21bd75d8f5dc30e9"
+dependencies = [
+ "agave-feature-set",
+ "solana-bpf-loader-program",
+ "solana-compute-budget-program",
+ "solana-hash 4.2.0",
+ "solana-loader-v4-program",
+ "solana-program-runtime",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-system-program",
+ "solana-vote-program",
+ "solana-zk-elgamal-proof-program",
+ "solana-zk-token-proof-program",
+]
+
+[[package]]
+name = "solana-builtins-default-costs"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3167997e8ac0fe100c4ed54503568d22204aeda56f4d3549e0c09a700b609aa8"
+dependencies = [
+ "agave-feature-set",
+ "ahash",
+ "log",
+ "solana-bpf-loader-program",
+ "solana-compute-budget-program",
+ "solana-loader-v4-program",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-system-program",
+ "solana-vote-program",
+]
+
+[[package]]
+name = "solana-clock"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8584296123df8fe229b95e2ebfd37ae637fe9db9b7d4dd677ac5a78e80dbfce"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-clock"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95cf11109c3b6115cc510f1e31f06fdd52f504271bc24ef5f1249fbbcae5f9f3"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 3.1.0",
+ "solana-sdk-macro 3.0.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-compute-budget"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b591fbaed6d9ab4cba6a5a82eb5df208072ced2e5b74c59e9d309ff87af0615f"
+dependencies = [
+ "solana-fee-structure",
+ "solana-program-runtime",
+]
+
+[[package]]
+name = "solana-compute-budget-instruction"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "006d9b6a34f9d7b719100653317990ed55e572107702104c054133b40f587306"
+dependencies = [
+ "agave-feature-set",
+ "log",
+ "solana-borsh 3.0.2",
+ "solana-builtins-default-costs",
+ "solana-compute-budget",
+ "solana-compute-budget-interface",
+ "solana-instruction 3.2.0",
+ "solana-packet",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-svm-transaction",
+ "solana-transaction-error 3.1.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-compute-budget-interface"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9"
+dependencies = [
+ "borsh 1.8.1",
+ "solana-instruction 3.2.0",
+ "solana-sdk-ids 3.1.0",
+]
+
+[[package]]
+name = "solana-compute-budget-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a22bcf5088ebe5cb2aa548580d0a466de813032b425707a7745a2a63a7764cdc"
+dependencies = [
+ "solana-program-runtime",
+]
+
+[[package]]
+name = "solana-cpi"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dc71126edddc2ba014622fc32d0f5e2e78ec6c5a1e0eb511b85618c09e9ea11"
+dependencies = [
+ "solana-account-info 2.3.0",
+ "solana-define-syscall 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "solana-stable-layout 2.2.1",
+]
+
+[[package]]
+name = "solana-cpi"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dea26709d867aada85d0d3617db0944215c8bb28d3745b912de7db13a23280c"
+dependencies = [
+ "solana-account-info 3.1.1",
+ "solana-define-syscall 4.0.1",
+ "solana-instruction 3.2.0",
+ "solana-program-error 3.0.1",
+ "solana-pubkey 4.1.0",
+ "solana-stable-layout 3.0.1",
+]
+
+[[package]]
+name = "solana-curve25519"
+version = "2.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eae4261b9a8613d10e77ac831a8fa60b6fa52b9b103df46d641deff9f9812a23"
+dependencies = [
+ "bytemuck",
+ "bytemuck_derive",
+ "curve25519-dalek",
+ "solana-define-syscall 2.3.0",
+ "subtle",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-curve25519"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14b4d2a4bf0d0b0a86c22111917e86e8bd39a7b31420fb2c7d73eb83761fc7af"
+dependencies = [
+ "bytemuck",
+ "bytemuck_derive",
+ "curve25519-dalek",
+ "solana-define-syscall 5.2.0",
+ "subtle",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-decode-error"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c781686a18db2f942e70913f7ca15dc120ec38dcab42ff7557db2c70c625a35"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "solana-define-syscall"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2"
+
+[[package]]
+name = "solana-define-syscall"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9697086a4e102d28a156b8d6b521730335d6951bd39a5e766512bbe09007cee"
+
+[[package]]
+name = "solana-define-syscall"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738"
+
+[[package]]
+name = "solana-define-syscall"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf8209ece2bd9f1450e672858ffc0e5c8c786ff6916d2a862b126dd0128f380f"
+
+[[package]]
+name = "solana-derivation-path"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "939756d798b25c5ec3cca10e06212bdca3b1443cb9bb740a38124f58b258737b"
+dependencies = [
+ "derivation-path",
+ "qstring",
+ "uriparse",
+]
+
+[[package]]
+name = "solana-derivation-path"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff71743072690fdbdfcdc37700ae1cb77485aaad49019473a81aee099b1e0b8c"
+dependencies = [
+ "derivation-path",
+ "qstring",
+ "uriparse",
+]
+
+[[package]]
+name = "solana-epoch-rewards"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-hash 2.3.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-epoch-rewards"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5e7b0ba210593ba8ddd39d6d234d81795d1671cebf3026baa10d5dc23ac42f0"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-hash 4.2.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-sdk-macro 3.0.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-epoch-schedule"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fce071fbddecc55d727b1d7ed16a629afe4f6e4c217bc8d00af3b785f6f67ed"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-epoch-schedule"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e5481e72cc4d52c169db73e4c0cd16de8bc943078aac587ec4817a75cc6388f"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 3.1.0",
+ "solana-sdk-macro 3.0.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-example-mocks"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84461d56cbb8bb8d539347151e0525b53910102e4bced875d49d5139708e39d3"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-address-lookup-table-interface 2.2.2",
+ "solana-clock 2.2.3",
+ "solana-hash 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-keccak-hasher 2.2.1",
+ "solana-message 2.4.0",
+ "solana-nonce 2.2.1",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-system-interface 1.0.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-feature-gate-interface"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f5c5382b449e8e4e3016fb05e418c53d57782d8b5c30aa372fc265654b956d"
+dependencies = [
+ "bincode",
+ "serde",
+ "serde_derive",
+ "solana-account 2.2.1",
+ "solana-account-info 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "solana-rent 2.2.1",
+ "solana-sdk-ids 2.2.1",
+ "solana-system-interface 1.0.0",
+]
+
+[[package]]
+name = "solana-feature-gate-interface"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75ca9b5cbb6f500f7fd73db5bd95640f71a83f04d6121a0e59a43b202dca2731"
+dependencies = [
+ "bincode",
+ "serde",
+ "serde_derive",
+ "solana-account 3.4.0",
+ "solana-account-info 3.1.1",
+ "solana-instruction 3.2.0",
+ "solana-program-error 3.0.1",
+ "solana-pubkey 4.1.0",
+ "solana-rent 4.5.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-system-interface 3.1.0",
+]
+
+[[package]]
+name = "solana-fee"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e506f6ec94e5733b0f2114b43bd8a2abac33a0256e19c65e1d119de008981339"
+dependencies = [
+ "agave-feature-set",
+ "solana-fee-structure",
+ "solana-svm-transaction",
+]
+
+[[package]]
+name = "solana-fee-calculator"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89bc408da0fb3812bc3008189d148b4d3e08252c79ad810b245482a3f70cd8d"
+dependencies = [
+ "log",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "solana-fee-calculator"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef67f01cc6a0c72e99a08d0d484683f995de4c80e9568728fa77d1537f9b7e09"
+dependencies = [
+ "log",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "solana-fee-structure"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2abdb1223eea8ec64136f39cb1ffcf257e00f915c957c35c0dd9e3f4e700b0"
+
+[[package]]
+name = "solana-hash"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b96e9f0300fa287b545613f007dfe20043d7812bee255f418c1eb649c93b63"
+dependencies = [
+ "borsh 1.8.1",
+ "bytemuck",
+ "bytemuck_derive",
+ "five8 0.2.1",
+ "js-sys",
+ "serde",
+ "serde_derive",
+ "solana-atomic-u64 2.2.1",
+ "solana-sanitize 2.2.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "solana-hash"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "337c246447142f660f778cf6cb582beba8e28deb05b3b24bfb9ffd7c562e5f41"
+dependencies = [
+ "solana-hash 4.2.0",
+]
+
+[[package]]
+name = "solana-hash"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8064ea1d591ec791be95245058ca40f4f5345d390c200069d0f79bbf55bfae55"
+dependencies = [
+ "bytemuck",
+ "bytemuck_derive",
+ "five8 1.0.0",
+ "serde",
+ "serde_derive",
+ "solana-atomic-u64 3.0.1",
+ "solana-sanitize 3.0.1",
+]
+
+[[package]]
+name = "solana-instruction"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad"
+dependencies = [
+ "bincode",
+ "borsh 1.8.1",
+ "getrandom 0.2.17",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "solana-define-syscall 2.3.0",
+ "solana-pubkey 2.4.0",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "solana-instruction"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6a6d22d0a6fdf345be294bb9afdcd40c296cdc095e64e7ceaa3bb3c2f608c1c"
+dependencies = [
+ "bincode",
+ "borsh 1.8.1",
+ "serde",
+ "serde_derive",
+ "solana-define-syscall 5.2.0",
+ "solana-instruction-error",
+ "solana-pubkey 4.1.0",
+]
+
+[[package]]
+name = "solana-instruction-error"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d3d048edaaeef5a3dc8c01853e585539a74417e4c2d43a9e2c161270045b838"
+dependencies = [
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-program-error 3.0.1",
+]
+
+[[package]]
+name = "solana-instructions-sysvar"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57"
+dependencies = [
+ "bitflags",
+ "solana-account-info 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "solana-sanitize 2.2.1",
+ "solana-sdk-ids 2.2.1",
+ "solana-serialize-utils 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-instructions-sysvar"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955"
+dependencies = [
+ "bitflags",
+ "solana-account-info 3.1.1",
+ "solana-instruction 3.2.0",
+ "solana-instruction-error",
+ "solana-program-error 3.0.1",
+ "solana-pubkey 3.0.0",
+ "solana-sanitize 3.0.1",
+ "solana-sdk-ids 3.1.0",
+ "solana-serialize-utils 3.1.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-keccak-hasher"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7aeb957fbd42a451b99235df4942d96db7ef678e8d5061ef34c9b34cae12f79"
+dependencies = [
+ "sha3",
+ "solana-define-syscall 2.3.0",
+ "solana-hash 2.3.0",
+ "solana-sanitize 2.2.1",
+]
+
+[[package]]
+name = "solana-keccak-hasher"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed1c0d16d6fdeba12291a1f068cdf0d479d9bff1141bf44afd7aa9d485f65ef8"
+dependencies = [
+ "sha3",
+ "solana-define-syscall 4.0.1",
+ "solana-hash 4.2.0",
+]
+
+[[package]]
+name = "solana-keypair"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "263d614c12aa267a3278703175fd6440552ca61bc960b5a02a4482720c53438b"
+dependencies = [
+ "ed25519-dalek",
+ "five8 1.0.0",
+ "five8_core 1.0.0",
+ "rand 0.9.5",
+ "solana-address 2.4.0",
+ "solana-seed-phrase 3.0.0",
+ "solana-signature 3.3.0",
+ "solana-signer 3.0.0",
+]
+
+[[package]]
+name = "solana-last-restart-slot"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6360ac2fdc72e7463565cd256eedcf10d7ef0c28a1249d261ec168c1b55cdd"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-last-restart-slot"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "426711c6564b790026e45cabec3c64b971864c48b6b2d83c0ebf52a118bb4cda"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 3.1.0",
+ "solana-sdk-macro 3.0.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-loader-v2-interface"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8ab08006dad78ae7cd30df8eea0539e207d08d91eaefb3e1d49a446e1c49654"
+dependencies = [
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+]
+
+[[package]]
+name = "solana-loader-v3-interface"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f7162a05b8b0773156b443bccd674ea78bb9aa406325b467ea78c06c99a63a2"
+dependencies = [
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-system-interface 1.0.0",
+]
+
+[[package]]
+name = "solana-loader-v3-interface"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dee44c9b1328c5c712c68966fb8de07b47f3e7bac006e74ddd1bb053d3e46e5d"
+dependencies = [
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-instruction 3.2.0",
+ "solana-pubkey 3.0.0",
+ "solana-sdk-ids 3.1.0",
+]
+
+[[package]]
+name = "solana-loader-v4-interface"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "706a777242f1f39a83e2a96a2a6cb034cb41169c6ecbee2cf09cb873d9659e7e"
+dependencies = [
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-system-interface 1.0.0",
+]
+
+[[package]]
+name = "solana-loader-v4-interface"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4c948b33ff81fa89699911b207059e493defdba9647eaf18f23abdf3674e0fb"
+dependencies = [
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-instruction 3.2.0",
+ "solana-pubkey 3.0.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-system-interface 2.0.0",
+]
+
+[[package]]
+name = "solana-loader-v4-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b5191cd34f04e4ec9fd5f2ac8a431ba9ffd6c827511fd35f2cae0256a0c6b12"
+dependencies = [
+ "log",
+ "solana-account 3.4.0",
+ "solana-bincode 3.1.0",
+ "solana-bpf-loader-program",
+ "solana-instruction 3.2.0",
+ "solana-loader-v3-interface 6.1.0",
+ "solana-loader-v4-interface 3.1.0",
+ "solana-packet",
+ "solana-program-runtime",
+ "solana-pubkey 4.1.0",
+ "solana-sbpf",
+ "solana-sdk-ids 3.1.0",
+ "solana-svm-log-collector",
+ "solana-svm-measure",
+ "solana-svm-type-overrides",
+ "solana-transaction-context",
+]
+
+[[package]]
+name = "solana-message"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1796aabce376ff74bf89b78d268fa5e683d7d7a96a0a4e4813ec34de49d5314b"
+dependencies = [
+ "bincode",
+ "blake3",
+ "lazy_static",
+ "serde",
+ "serde_derive",
+ "solana-bincode 2.2.1",
+ "solana-hash 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sanitize 2.2.1",
+ "solana-sdk-ids 2.2.1",
+ "solana-short-vec 2.2.1",
+ "solana-system-interface 1.0.0",
+ "solana-transaction-error 2.2.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "solana-message"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0448b1fd891c5f46491e5dc7d9986385ba3c852c340db2911dd29faa01d2b08d"
+dependencies = [
+ "bincode",
+ "blake3",
+ "lazy_static",
+ "serde",
+ "serde_derive",
+ "solana-address 2.4.0",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-sanitize 3.0.1",
+ "solana-sdk-ids 3.1.0",
+ "solana-short-vec 3.3.0",
+ "solana-transaction-error 3.1.0",
+]
+
+[[package]]
+name = "solana-msg"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092"
+dependencies = [
+ "solana-define-syscall 2.3.0",
+]
+
+[[package]]
+name = "solana-msg"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "726b7cbbc6be6f1c6f29146ac824343b9415133eee8cce156452ad1db93f8008"
+dependencies = [
+ "solana-define-syscall 5.2.0",
+]
+
+[[package]]
+name = "solana-native-token"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61515b880c36974053dd499c0510066783f0cc6ac17def0c7ef2a244874cf4a9"
+
+[[package]]
+name = "solana-native-token"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae8dd4c280dca9d046139eb5b7a5ac9ad10403fbd64964c7d7571214950d758f"
+
+[[package]]
+name = "solana-nonce"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703e22eb185537e06204a5bd9d509b948f0066f2d1d814a6f475dafb3ddf1325"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-fee-calculator 2.2.1",
+ "solana-hash 2.3.0",
+ "solana-pubkey 2.4.0",
+ "solana-sha256-hasher 2.3.0",
+]
+
+[[package]]
+name = "solana-nonce"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbc469152a63284ef959b80c59cda015262a021da55d3b8fe42171d89c4b64f8"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-fee-calculator 3.2.2",
+ "solana-hash 4.2.0",
+ "solana-pubkey 4.1.0",
+ "solana-sha256-hasher 3.1.0",
+]
+
+[[package]]
+name = "solana-nonce-account"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "805fd25b29e5a1a0e6c3dd6320c9da80f275fbe4ff6e392617c303a2085c435e"
+dependencies = [
+ "solana-account 3.4.0",
+ "solana-hash 3.1.0",
+ "solana-nonce 3.1.0",
+ "solana-sdk-ids 3.1.0",
+]
+
+[[package]]
+name = "solana-packet"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad62e1045c2347a0c0e219a6ceb0abfe904be622920996bfcac8d116fabe3c7"
+dependencies = [
+ "bitflags",
+ "solana-pubkey 4.1.0",
+]
+
+[[package]]
+name = "solana-poseidon"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "737b8ab25bf4cc8e618f80f1fe40709b2ace708bc764a36b8a4c81eea8c07034"
+dependencies = [
+ "ark-bn254 0.4.0",
+ "ark-bn254 0.5.0",
+ "light-poseidon 0.2.0",
+ "light-poseidon 0.4.1",
+ "solana-define-syscall 4.0.1",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-precompile-error"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cafcd950de74c6c39d55dc8ca108bbb007799842ab370ef26cf45a34453c31e1"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "solana-program"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98eca145bd3545e2fbb07166e895370576e47a00a7d824e325390d33bf467210"
+dependencies = [
+ "bincode",
+ "blake3",
+ "borsh 0.10.4",
+ "borsh 1.8.1",
+ "bs58",
+ "bytemuck",
+ "console_error_panic_hook",
+ "console_log",
+ "getrandom 0.2.17",
+ "lazy_static",
+ "log",
+ "memoffset",
+ "num-bigint 0.4.8",
+ "num-derive",
+ "num-traits",
+ "rand 0.8.8",
+ "serde",
+ "serde_bytes",
+ "serde_derive",
+ "solana-account-info 2.3.0",
+ "solana-address-lookup-table-interface 2.2.2",
+ "solana-atomic-u64 2.2.1",
+ "solana-big-mod-exp 2.2.1",
+ "solana-bincode 2.2.1",
+ "solana-blake3-hasher 2.2.1",
+ "solana-borsh 2.2.1",
+ "solana-clock 2.2.3",
+ "solana-cpi 2.2.1",
+ "solana-decode-error",
+ "solana-define-syscall 2.3.0",
+ "solana-epoch-rewards 2.2.1",
+ "solana-epoch-schedule 2.2.1",
+ "solana-example-mocks",
+ "solana-feature-gate-interface 2.2.2",
+ "solana-fee-calculator 2.2.1",
+ "solana-hash 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-instructions-sysvar 2.2.2",
+ "solana-keccak-hasher 2.2.1",
+ "solana-last-restart-slot 2.2.1",
+ "solana-loader-v2-interface",
+ "solana-loader-v3-interface 5.0.0",
+ "solana-loader-v4-interface 2.2.1",
+ "solana-message 2.4.0",
+ "solana-msg 2.2.1",
+ "solana-native-token 2.3.0",
+ "solana-nonce 2.2.1",
+ "solana-program-entrypoint 2.3.0",
+ "solana-program-error 2.2.2",
+ "solana-program-memory 2.3.1",
+ "solana-program-option",
+ "solana-program-pack",
+ "solana-pubkey 2.4.0",
+ "solana-rent 2.2.1",
+ "solana-sanitize 2.2.1",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-secp256k1-recover 2.2.1",
+ "solana-serde-varint 2.2.2",
+ "solana-serialize-utils 2.2.1",
+ "solana-sha256-hasher 2.3.0",
+ "solana-short-vec 2.2.1",
+ "solana-slot-hashes 2.2.1",
+ "solana-slot-history 2.2.1",
+ "solana-stable-layout 2.2.1",
+ "solana-stake-interface 1.2.1",
+ "solana-system-interface 1.0.0",
+ "solana-sysvar 2.3.0",
+ "solana-sysvar-id 2.2.1",
+ "solana-vote-interface 2.2.6",
+ "thiserror 2.0.21",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "solana-program-entrypoint"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32ce041b1a0ed275290a5008ee1a4a6c48f5054c8a3d78d313c08958a06aedbd"
+dependencies = [
+ "solana-account-info 2.3.0",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+]
+
+[[package]]
+name = "solana-program-entrypoint"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84c9b0a1ff494e05f503a08b3d51150b73aa639544631e510279d6375f290997"
+dependencies = [
+ "solana-account-info 3.1.1",
+ "solana-define-syscall 4.0.1",
+ "solana-program-error 3.0.1",
+ "solana-pubkey 4.1.0",
+]
+
+[[package]]
+name = "solana-program-error"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ee2e0217d642e2ea4bee237f37bd61bb02aec60da3647c48ff88f6556ade775"
+dependencies = [
+ "borsh 1.8.1",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-msg 2.2.1",
+ "solana-pubkey 2.4.0",
+]
+
+[[package]]
+name = "solana-program-error"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6"
+
+[[package]]
+name = "solana-program-memory"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a5426090c6f3fd6cfdc10685322fede9ca8e5af43cd6a59e98bfe4e91671712"
+dependencies = [
+ "solana-define-syscall 2.3.0",
+]
+
+[[package]]
+name = "solana-program-memory"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4068648649653c2c50546e9a7fb761791b5ab0cda054c771bb5808d3a4b9eb52"
+dependencies = [
+ "solana-define-syscall 4.0.1",
+]
+
+[[package]]
+name = "solana-program-option"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0"
+
+[[package]]
+name = "solana-program-pack"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "319f0ef15e6e12dc37c597faccb7d62525a509fec5f6975ecb9419efddeb277b"
+dependencies = [
+ "solana-program-error 2.2.2",
+]
+
+[[package]]
+name = "solana-program-runtime"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6c7f89c89d5ff25f64a41c8cb00478b1d62f941f14a7dd8537c9e50bb2acc92"
+dependencies = [
+ "base64 0.22.1",
+ "bincode",
+ "cfg-if",
+ "itertools 0.14.0",
+ "log",
+ "percentage",
+ "rand 0.9.5",
+ "serde",
+ "solana-account 3.4.0",
+ "solana-account-info 3.1.1",
+ "solana-clock 3.0.1",
+ "solana-epoch-rewards 3.0.1",
+ "solana-epoch-schedule 3.0.0",
+ "solana-fee-structure",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-last-restart-slot 3.0.1",
+ "solana-loader-v3-interface 6.1.0",
+ "solana-program-entrypoint 3.1.1",
+ "solana-pubkey 4.1.0",
+ "solana-rent 3.1.0",
+ "solana-sbpf",
+ "solana-sdk-ids 3.1.0",
+ "solana-slot-hashes 3.0.1",
+ "solana-stable-layout 3.0.1",
+ "solana-stake-interface 2.0.2",
+ "solana-svm-callback",
+ "solana-svm-feature-set",
+ "solana-svm-log-collector",
+ "solana-svm-measure",
+ "solana-svm-timings",
+ "solana-svm-transaction",
+ "solana-svm-type-overrides",
+ "solana-system-interface 3.1.0",
+ "solana-sysvar 3.1.1",
+ "solana-sysvar-id 3.1.0",
+ "solana-transaction-context",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-pubkey"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b62adb9c3261a052ca1f999398c388f1daf558a1b492f60a6d9e64857db4ff1"
+dependencies = [
+ "borsh 0.10.4",
+ "borsh 1.8.1",
+ "bytemuck",
+ "bytemuck_derive",
+ "curve25519-dalek",
+ "five8 0.2.1",
+ "five8_const 0.1.4",
+ "getrandom 0.2.17",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-atomic-u64 2.2.1",
+ "solana-decode-error",
+ "solana-define-syscall 2.3.0",
+ "solana-sanitize 2.2.1",
+ "solana-sha256-hasher 2.3.0",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "solana-pubkey"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941"
+dependencies = [
+ "solana-address 1.1.0",
+]
+
+[[package]]
+name = "solana-pubkey"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b06bd918d60111ee1f97de817113e2040ca0cedb740099ee8d646233f6b906c"
+dependencies = [
+ "solana-address 2.4.0",
+]
+
+[[package]]
+name = "solana-rent"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1aea8fdea9de98ca6e8c2da5827707fb3842833521b528a713810ca685d2480"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-rent"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e860d5499a705369778647e97d760f7670adfb6fc8419dd3d568deccd46d5487"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 3.1.0",
+ "solana-sdk-macro 3.0.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-rent"
+version = "4.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e7c7590d4e53446a00c363783d98199d8c8f5099ca60eaf2a0f1f50d0e7a31e"
+dependencies = [
+ "solana-sdk-macro 3.0.1",
+]
+
+[[package]]
+name = "solana-sanitize"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf"
+
+[[package]]
+name = "solana-sanitize"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9"
+
+[[package]]
+name = "solana-sbpf"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "733b3657a0fab205102b799dbe17f85d3972cf984232c1b0b108fa6ba438e382"
+dependencies = [
+ "byteorder",
+ "combine",
+ "hash32",
+ "libc",
+ "log",
+ "rand 0.8.8",
+ "rustc-demangle",
+ "thiserror 2.0.21",
+ "winapi",
+]
+
+[[package]]
+name = "solana-sdk-ids"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5d8b9cc68d5c88b062a33e23a6466722467dde0035152d8fb1afbcdf350a5f"
+dependencies = [
+ "solana-pubkey 2.4.0",
+]
+
+[[package]]
+name = "solana-sdk-ids"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "def234c1956ff616d46c9dd953f251fa7096ddbaa6d52b165218de97882b7280"
+dependencies = [
+ "solana-address 2.4.0",
+]
+
+[[package]]
+name = "solana-sdk-macro"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86280da8b99d03560f6ab5aca9de2e38805681df34e0bb8f238e69b29433b9df"
+dependencies = [
+ "bs58",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "solana-sdk-macro"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8765316242300c48242d84a41614cb3388229ec353ba464f6fe62a733e41806f"
+dependencies = [
+ "bs58",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "solana-secp256k1-recover"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa3120b6cdaa270f39444f5093a90a7b03d296d362878f7a6991d6de3bbe496"
+dependencies = [
+ "libsecp256k1",
+ "solana-define-syscall 2.3.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-secp256k1-recover"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce0d2e8d53946f04e789476d6c407e4997b7fb412483df5fc10c075c65cb2edf"
+dependencies = [
+ "k256",
+ "solana-define-syscall 5.2.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-security-txt"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c94a02d486b28f219a4f8f5d7dd93cbfbb93c9f466cb7871c22e50cd5ae9a7a2"
+
+[[package]]
+name = "solana-seed-derivable"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beb82b5adb266c6ea90e5cf3967235644848eac476c5a1f2f9283a143b7c97f"
+dependencies = [
+ "solana-derivation-path 2.2.1",
+]
+
+[[package]]
+name = "solana-seed-derivable"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff7bdb72758e3bec33ed0e2658a920f1f35dfb9ed576b951d20d63cb61ecd95c"
+dependencies = [
+ "solana-derivation-path 3.0.0",
+]
+
+[[package]]
+name = "solana-seed-phrase"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36187af2324f079f65a675ec22b31c24919cb4ac22c79472e85d819db9bbbc15"
+dependencies = [
+ "hmac",
+ "pbkdf2",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "solana-seed-phrase"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de"
+dependencies = [
+ "hmac",
+ "pbkdf2",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "solana-serde-varint"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a7e155eba458ecfb0107b98236088c3764a09ddf0201ec29e52a0be40857113"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "solana-serde-varint"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "950e5b83e839dc0f92c66afc124bb8f40e89bc90f0579e8ec5499296d27f54e3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "solana-serialize-utils"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "817a284b63197d2b27afdba829c5ab34231da4a9b4e763466a003c40ca4f535e"
+dependencies = [
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sanitize 2.2.1",
+]
+
+[[package]]
+name = "solana-serialize-utils"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d7cc401931d178472358e6b78dc72d031dc08f752d7410f0e8bd259dd6f02fa"
+dependencies = [
+ "solana-instruction-error",
+ "solana-pubkey 4.1.0",
+ "solana-sanitize 3.0.1",
+]
+
+[[package]]
+name = "solana-sha256-hasher"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa3feb32c28765f6aa1ce8f3feac30936f16c5c3f7eb73d63a5b8f6f8ecdc44"
+dependencies = [
+ "sha2 0.10.9",
+ "solana-define-syscall 2.3.0",
+ "solana-hash 2.3.0",
+]
+
+[[package]]
+name = "solana-sha256-hasher"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f"
+dependencies = [
+ "sha2 0.10.9",
+ "solana-define-syscall 4.0.1",
+ "solana-hash 4.2.0",
+]
+
+[[package]]
+name = "solana-short-vec"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c54c66f19b9766a56fa0057d060de8378676cb64987533fa088861858fc5a69"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "solana-short-vec"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c75782529cc4521114c1ecd71c7e2cdebd41a6fe9844b5d088fd36ef08c57c36"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "solana-signature"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c"
+dependencies = [
+ "five8 0.2.1",
+ "solana-sanitize 2.2.1",
+]
+
+[[package]]
+name = "solana-signature"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "132a93134f1262aa832f1849b83bec6c9945669b866da18661a427943b9e801e"
+dependencies = [
+ "ed25519-dalek",
+ "five8 1.0.0",
+ "serde",
+ "serde-big-array",
+ "serde_derive",
+ "solana-sanitize 3.0.1",
+ "wincode",
+]
+
+[[package]]
+name = "solana-signer"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c41991508a4b02f021c1342ba00bcfa098630b213726ceadc7cb032e051975b"
+dependencies = [
+ "solana-pubkey 2.4.0",
+ "solana-signature 2.3.0",
+ "solana-transaction-error 2.2.1",
+]
+
+[[package]]
+name = "solana-signer"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bfea97951fee8bae0d6038f39a5efcb6230ecdfe33425ac75196d1a1e3e3235"
+dependencies = [
+ "solana-pubkey 3.0.0",
+ "solana-signature 3.3.0",
+ "solana-transaction-error 3.1.0",
+]
+
+[[package]]
+name = "solana-slot-hashes"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c8691982114513763e88d04094c9caa0376b867a29577939011331134c301ce"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-hash 2.3.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-slot-hashes"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2585f70191623887329dfb5078da3a00e15e3980ea67f42c2e10b07028419f43"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-hash 4.2.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-slot-history"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97ccc1b2067ca22754d5283afb2b0126d61eae734fc616d23871b0943b0d935e"
+dependencies = [
+ "bv",
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 2.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-slot-history"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0622d03a823770f7763afd866e012b296d5a3cbbbe51e110b5bd9ab3441efdca"
+dependencies = [
+ "bv",
+ "serde",
+ "serde_derive",
+ "solana-sdk-ids 3.1.0",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-stable-layout"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f14f7d02af8f2bc1b5efeeae71bc1c2b7f0f65cd75bcc7d8180f2c762a57f54"
+dependencies = [
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+]
+
+[[package]]
+name = "solana-stable-layout"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9f6a291ba063a37780af29e7db14bdd3dc447584d8ba5b3fc4b88e2bbc982fa"
+dependencies = [
+ "solana-instruction 3.2.0",
+ "solana-pubkey 4.1.0",
+]
+
+[[package]]
+name = "solana-stake-interface"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5269e89fde216b4d7e1d1739cf5303f8398a1ff372a81232abbee80e554a838c"
+dependencies = [
+ "borsh 0.10.4",
+ "borsh 1.8.1",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-clock 2.2.3",
+ "solana-cpi 2.2.1",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "solana-system-interface 1.0.0",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-stake-interface"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9bc26191b533f9a6e5a14cca05174119819ced680a80febff2f5051a713f0db"
+dependencies = [
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-clock 3.0.1",
+ "solana-cpi 3.1.0",
+ "solana-instruction 3.2.0",
+ "solana-program-error 3.0.1",
+ "solana-pubkey 3.0.0",
+ "solana-system-interface 2.0.0",
+ "solana-sysvar 3.1.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-svm-callback"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4006b0da7e50cba514ced6b47bcf8f9591552458200e361fd4bdef4068cb2fed"
+dependencies = [
+ "solana-account 3.4.0",
+ "solana-clock 3.0.1",
+ "solana-precompile-error",
+ "solana-pubkey 4.1.0",
+]
+
+[[package]]
+name = "solana-svm-feature-set"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24ea15c0d91403375e3d017cc09780cf138b629abba4ccaaa7cf66b1afea1059"
+
+[[package]]
+name = "solana-svm-log-collector"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efb7d3ccd3a51b85807ff16b2f513069e8b55e220b280774a3e9b899bcb81987"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "solana-svm-measure"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d70c9972c1f03cb2bbc64d23dc2079419a66d89b49d6b44f79206530551ddc8c"
+
+[[package]]
+name = "solana-svm-timings"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20f3d66aa88c9001a076362108f7967d6a00d121ba38428e56928935566ed5bd"
+dependencies = [
+ "eager",
+ "enum-iterator",
+ "solana-pubkey 4.1.0",
+]
+
+[[package]]
+name = "solana-svm-transaction"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "067861db805d135a6fbe489bf2b74d701f270df8d03afd3257f7d51a2ff3467e"
+dependencies = [
+ "solana-hash 4.2.0",
+ "solana-message 3.1.0",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-signature 3.3.0",
+ "solana-transaction",
+]
+
+[[package]]
+name = "solana-svm-type-overrides"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e41661ebf0edcc296b15251c08fee0ad2da3257e6ab86cea2a0a8f6fba642c6"
+dependencies = [
+ "rand 0.9.5",
+]
+
+[[package]]
+name = "solana-system-interface"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94d7c18cb1a91c6be5f5a8ac9276a1d7c737e39a21beba9ea710ab4b9c63bc90"
+dependencies = [
+ "js-sys",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "solana-system-interface"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e1790547bfc3061f1ee68ea9d8dc6c973c02a163697b24263a8e9f2e6d4afa2"
+dependencies = [
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-instruction 3.2.0",
+ "solana-msg 3.1.0",
+ "solana-program-error 3.0.1",
+ "solana-pubkey 3.0.0",
+]
+
+[[package]]
+name = "solana-system-interface"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a95a6f2e23ed861d6444ad4a6d6896c418d7d101b960787e65a8e33157cee81b"
+dependencies = [
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-address 2.4.0",
+ "solana-instruction 3.2.0",
+ "solana-msg 3.1.0",
+ "solana-program-error 3.0.1",
+]
+
+[[package]]
+name = "solana-system-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "450479004fee3396c88cc4aa2f9b2b8db9c77be42ee7c1c53e6fac9eaec5fd51"
+dependencies = [
+ "bincode",
+ "log",
+ "serde",
+ "solana-account 3.4.0",
+ "solana-bincode 3.1.0",
+ "solana-fee-calculator 3.2.2",
+ "solana-instruction 3.2.0",
+ "solana-nonce 3.1.0",
+ "solana-nonce-account",
+ "solana-packet",
+ "solana-program-runtime",
+ "solana-pubkey 4.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-svm-log-collector",
+ "solana-svm-type-overrides",
+ "solana-system-interface 3.1.0",
+ "solana-sysvar 3.1.1",
+ "solana-transaction-context",
+]
+
+[[package]]
+name = "solana-sysvar"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c3595f95069f3d90f275bb9bd235a1973c4d059028b0a7f81baca2703815db"
+dependencies = [
+ "base64 0.22.1",
+ "bincode",
+ "bytemuck",
+ "bytemuck_derive",
+ "lazy_static",
+ "serde",
+ "serde_derive",
+ "solana-account-info 2.3.0",
+ "solana-clock 2.2.3",
+ "solana-define-syscall 2.3.0",
+ "solana-epoch-rewards 2.2.1",
+ "solana-epoch-schedule 2.2.1",
+ "solana-fee-calculator 2.2.1",
+ "solana-hash 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-instructions-sysvar 2.2.2",
+ "solana-last-restart-slot 2.2.1",
+ "solana-program-entrypoint 2.3.0",
+ "solana-program-error 2.2.2",
+ "solana-program-memory 2.3.1",
+ "solana-pubkey 2.4.0",
+ "solana-rent 2.2.1",
+ "solana-sanitize 2.2.1",
+ "solana-sdk-ids 2.2.1",
+ "solana-sdk-macro 2.2.1",
+ "solana-slot-hashes 2.2.1",
+ "solana-slot-history 2.2.1",
+ "solana-stake-interface 1.2.1",
+ "solana-sysvar-id 2.2.1",
+]
+
+[[package]]
+name = "solana-sysvar"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6690d3dd88f15c21edff68eb391ef8800df7a1f5cec84ee3e8d1abf05affdf74"
+dependencies = [
+ "base64 0.22.1",
+ "bincode",
+ "lazy_static",
+ "serde",
+ "serde_derive",
+ "solana-account-info 3.1.1",
+ "solana-clock 3.0.1",
+ "solana-define-syscall 4.0.1",
+ "solana-epoch-rewards 3.0.1",
+ "solana-epoch-schedule 3.0.0",
+ "solana-fee-calculator 3.2.2",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-last-restart-slot 3.0.1",
+ "solana-program-entrypoint 3.1.1",
+ "solana-program-error 3.0.1",
+ "solana-program-memory 3.1.0",
+ "solana-pubkey 4.1.0",
+ "solana-rent 3.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-sdk-macro 3.0.1",
+ "solana-slot-hashes 3.0.1",
+ "solana-slot-history 3.0.1",
+ "solana-sysvar-id 3.1.0",
+]
+
+[[package]]
+name = "solana-sysvar-id"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5762b273d3325b047cfda250787f8d796d781746860d5d0a746ee29f3e8812c1"
+dependencies = [
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+]
+
+[[package]]
+name = "solana-sysvar-id"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17358d1e9a13e5b9c2264d301102126cf11a47fd394cdf3dec174fe7bc96e1de"
+dependencies = [
+ "solana-address 2.4.0",
+ "solana-sdk-ids 3.1.0",
+]
+
+[[package]]
+name = "solana-transaction"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96697cff5075a028265324255efed226099f6d761ca67342b230d09f72cc48d2"
+dependencies = [
+ "bincode",
+ "serde",
+ "serde_derive",
+ "solana-address 2.4.0",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-instruction-error",
+ "solana-message 3.1.0",
+ "solana-sanitize 3.0.1",
+ "solana-sdk-ids 3.1.0",
+ "solana-short-vec 3.3.0",
+ "solana-signature 3.3.0",
+ "solana-signer 3.0.0",
+ "solana-transaction-error 3.1.0",
+]
+
+[[package]]
+name = "solana-transaction-context"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecefe8b30e334e2891ca82da35becd9a3f4c16021d9ca782e2a82adf31084fa3"
+dependencies = [
+ "bincode",
+ "serde",
+ "solana-account 3.4.0",
+ "solana-instruction 3.2.0",
+ "solana-instructions-sysvar 3.0.0",
+ "solana-pubkey 4.1.0",
+ "solana-rent 3.1.0",
+ "solana-sbpf",
+ "solana-sdk-ids 3.1.0",
+]
+
+[[package]]
+name = "solana-transaction-error"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "222a9dc8fdb61c6088baab34fc3a8b8473a03a7a5fd404ed8dd502fa79b67cb1"
+dependencies = [
+ "solana-instruction 2.3.3",
+ "solana-sanitize 2.2.1",
+]
+
+[[package]]
+name = "solana-transaction-error"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8396904805b0b385b9de115a652fe80fd01e5b98ce0513f4fcd8184ada9bb792"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "solana-instruction-error",
+ "solana-sanitize 3.0.1",
+]
+
+[[package]]
+name = "solana-vote-interface"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3"
+dependencies = [
+ "bincode",
+ "num-derive",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "solana-clock 2.2.3",
+ "solana-decode-error",
+ "solana-hash 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-rent 2.2.1",
+ "solana-sdk-ids 2.2.1",
+ "solana-serde-varint 2.2.2",
+ "solana-serialize-utils 2.2.1",
+ "solana-short-vec 2.2.1",
+ "solana-system-interface 1.0.0",
+]
+
+[[package]]
+name = "solana-vote-interface"
+version = "5.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d444ce30b6b4f9c281ba06061ea96638d063b53c2171b1e41ba02ebff79ed28f"
+dependencies = [
+ "bincode",
+ "cfg_eval",
+ "num-derive",
+ "num-traits",
+ "serde",
+ "serde_derive",
+ "serde_with",
+ "solana-clock 3.0.1",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-instruction-error",
+ "solana-pubkey 4.1.0",
+ "solana-rent 4.5.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-serde-varint 3.0.1",
+ "solana-serialize-utils 3.1.1",
+ "solana-short-vec 3.3.0",
+ "solana-system-interface 3.1.0",
+]
+
+[[package]]
+name = "solana-vote-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4537fd6efe65f53ccd28d54d2ad43275b024834a4a8ca4dfa4babfa01e6d11ab"
+dependencies = [
+ "agave-feature-set",
+ "bincode",
+ "log",
+ "num-derive",
+ "num-traits",
+ "serde",
+ "solana-account 3.4.0",
+ "solana-bincode 3.1.0",
+ "solana-bls-signatures",
+ "solana-clock 3.0.1",
+ "solana-epoch-schedule 3.0.0",
+ "solana-hash 4.2.0",
+ "solana-instruction 3.2.0",
+ "solana-keypair",
+ "solana-packet",
+ "solana-program-runtime",
+ "solana-pubkey 4.1.0",
+ "solana-rent 3.1.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-signer 3.0.0",
+ "solana-slot-hashes 3.0.1",
+ "solana-system-interface 3.1.0",
+ "solana-transaction",
+ "solana-transaction-context",
+ "solana-vote-interface 5.1.1",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "solana-zk-elgamal-proof-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf97ec816e8c6d45b5f05e21381bcc4b856cb3c89b69e465ee20972368b4c31"
+dependencies = [
+ "agave-feature-set",
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "solana-instruction 3.2.0",
+ "solana-program-runtime",
+ "solana-sdk-ids 3.1.0",
+ "solana-svm-log-collector",
+ "solana-zk-sdk 5.0.1",
+]
+
+[[package]]
+name = "solana-zk-sdk"
+version = "2.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b9fc6ec37d16d0dccff708ed1dd6ea9ba61796700c3bb7c3b401973f10f63b"
+dependencies = [
+ "aes-gcm-siv",
+ "base64 0.22.1",
+ "bincode",
+ "bytemuck",
+ "bytemuck_derive",
+ "curve25519-dalek",
+ "itertools 0.12.1",
+ "js-sys",
+ "merlin",
+ "num-derive",
+ "num-traits",
+ "rand 0.8.8",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "sha3",
+ "solana-derivation-path 2.2.1",
+ "solana-instruction 2.3.3",
+ "solana-pubkey 2.4.0",
+ "solana-sdk-ids 2.2.1",
+ "solana-seed-derivable 2.2.1",
+ "solana-seed-phrase 2.2.1",
+ "solana-signature 2.3.0",
+ "solana-signer 2.2.1",
+ "subtle",
+ "thiserror 2.0.21",
+ "wasm-bindgen",
+ "zeroize",
+]
+
+[[package]]
+name = "solana-zk-sdk"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09670ff59f60e6ddc2209c2e4353992a9b9f01d4e244f3e9d67bd5146e33d388"
+dependencies = [
+ "aes-gcm-siv",
+ "base64 0.22.1",
+ "bincode",
+ "bytemuck",
+ "bytemuck_derive",
+ "curve25519-dalek",
+ "itertools 0.14.0",
+ "merlin",
+ "num-derive",
+ "num-traits",
+ "rand 0.8.8",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "sha3",
+ "solana-address 2.4.0",
+ "solana-derivation-path 3.0.0",
+ "solana-instruction 3.2.0",
+ "solana-sdk-ids 3.1.0",
+ "solana-seed-derivable 3.0.0",
+ "solana-seed-phrase 3.0.0",
+ "solana-signature 3.3.0",
+ "solana-signer 3.0.0",
+ "subtle",
+ "thiserror 2.0.21",
+ "zeroize",
+]
+
+[[package]]
+name = "solana-zk-token-proof-program"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f08a8be7008cec95d74c0ded5ae10b6869bd06bd9761c800e7e098bd45097e6"
+dependencies = [
+ "solana-program-runtime",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "spl-discriminator"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7398da23554a31660f17718164e31d31900956054f54f52d5ec1be51cb4f4b3"
+dependencies = [
+ "bytemuck",
+ "solana-program-error 2.2.2",
+ "solana-sha256-hasher 2.3.0",
+ "spl-discriminator-derive",
+]
+
+[[package]]
+name = "spl-discriminator-derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750"
+dependencies = [
+ "quote",
+ "spl-discriminator-syn",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "spl-discriminator-syn"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d1dbc82ab91422345b6df40a79e2b78c7bce1ebb366da323572dd60b7076b67"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sha2 0.10.9",
+ "syn 2.0.119",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-elgamal-registry"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce0f668975d2b0536e8a8fd60e56a05c467f06021dae037f1d0cfed0de2e231d"
+dependencies = [
+ "bytemuck",
+ "solana-program",
+ "solana-zk-sdk 2.3.13",
+ "spl-pod",
+ "spl-token-confidential-transfer-proof-extraction",
+]
+
+[[package]]
+name = "spl-memo"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f09647c0974e33366efeb83b8e2daebb329f0420149e74d3a4bd2c08cf9f7cb"
+dependencies = [
+ "solana-account-info 2.3.0",
+ "solana-instruction 2.3.3",
+ "solana-msg 2.2.1",
+ "solana-program-entrypoint 2.3.0",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+]
+
+[[package]]
+name = "spl-pod"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d994afaf86b779104b4a95ba9ca75b8ced3fdb17ee934e38cb69e72afbe17799"
+dependencies = [
+ "borsh 1.8.1",
+ "bytemuck",
+ "bytemuck_derive",
+ "num-derive",
+ "num-traits",
+ "solana-decode-error",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "solana-program-option",
+ "solana-pubkey 2.4.0",
+ "solana-zk-sdk 2.3.13",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "spl-program-error"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d39b5186f42b2b50168029d81e58e800b690877ef0b30580d107659250da1d1"
+dependencies = [
+ "num-derive",
+ "num-traits",
+ "solana-program",
+ "spl-program-error-derive",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-program-error-derive"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d375dd76c517836353e093c2dbb490938ff72821ab568b545fd30ab3256b3e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sha2 0.10.9",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "spl-tlv-account-resolution"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd99ff1e9ed2ab86e3fd582850d47a739fec1be9f4661cba1782d3a0f26805f3"
+dependencies = [
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "solana-account-info 2.3.0",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "spl-discriminator",
+ "spl-pod",
+ "spl-program-error",
+ "spl-type-length-value",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-token"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed320a6c934128d4f7e54fe00e16b8aeaecf215799d060ae14f93378da6dc834"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "num_enum",
+ "solana-program",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-token-2022"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b27f7405010ef816587c944536b0eafbcc35206ab6ba0f2ca79f1d28e488f4f"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "num_enum",
+ "solana-program",
+ "solana-security-txt",
+ "solana-zk-sdk 2.3.13",
+ "spl-elgamal-registry",
+ "spl-memo",
+ "spl-pod",
+ "spl-token",
+ "spl-token-confidential-transfer-ciphertext-arithmetic",
+ "spl-token-confidential-transfer-proof-extraction",
+ "spl-token-confidential-transfer-proof-generation",
+ "spl-token-group-interface",
+ "spl-token-metadata-interface",
+ "spl-transfer-hook-interface",
+ "spl-type-length-value",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-token-confidential-transfer-ciphertext-arithmetic"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170378693c5516090f6d37ae9bad2b9b6125069be68d9acd4865bbe9fc8499fd"
+dependencies = [
+ "base64 0.22.1",
+ "bytemuck",
+ "solana-curve25519 2.3.13",
+ "solana-zk-sdk 2.3.13",
+]
+
+[[package]]
+name = "spl-token-confidential-transfer-proof-extraction"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff2d6a445a147c9d6dd77b8301b1e116c8299601794b558eafa409b342faf96"
+dependencies = [
+ "bytemuck",
+ "solana-curve25519 2.3.13",
+ "solana-program",
+ "solana-zk-sdk 2.3.13",
+ "spl-pod",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "spl-token-confidential-transfer-proof-generation"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8627184782eec1894de8ea26129c61303f1f0adeed65c20e0b10bc584f09356d"
+dependencies = [
+ "curve25519-dalek",
+ "solana-zk-sdk 2.3.13",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-token-group-interface"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d595667ed72dbfed8c251708f406d7c2814a3fa6879893b323d56a10bedfc799"
+dependencies = [
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "spl-discriminator",
+ "spl-pod",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-token-metadata-interface"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfb9c89dbc877abd735f05547dcf9e6e12c00c11d6d74d8817506cab4c99fdbb"
+dependencies = [
+ "borsh 1.8.1",
+ "num-derive",
+ "num-traits",
+ "solana-borsh 2.2.1",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "spl-discriminator",
+ "spl-pod",
+ "spl-type-length-value",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-transfer-hook-interface"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aa7503d52107c33c88e845e1351565050362c2314036ddf19a36cd25137c043"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "solana-account-info 2.3.0",
+ "solana-cpi 2.2.1",
+ "solana-decode-error",
+ "solana-instruction 2.3.3",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "solana-pubkey 2.4.0",
+ "spl-discriminator",
+ "spl-pod",
+ "spl-program-error",
+ "spl-tlv-account-resolution",
+ "spl-type-length-value",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "spl-type-length-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba70ef09b13af616a4c987797870122863cba03acc4284f226a4473b043923f9"
+dependencies = [
+ "bytemuck",
+ "num-derive",
+ "num-traits",
+ "solana-account-info 2.3.0",
+ "solana-decode-error",
+ "solana-msg 2.2.1",
+ "solana-program-error 2.2.2",
+ "spl-discriminator",
+ "spl-pod",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "threadpool"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
+dependencies = [
+ "num_cpus",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.17+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
+dependencies = [
+ "indexmap",
+ "toml_datetime 1.1.2+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "unreachable"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56"
+dependencies = [
+ "void",
+]
+
+[[package]]
+name = "uriparse"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff"
+dependencies = [
+ "fnv",
+ "lazy_static",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "wincode"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "657690780ce23e6f66576a782ffd88eb353512381817029cc1d7a99154bb6d1f"
+dependencies = [
+ "pastey",
+ "proc-macro2",
+ "quote",
+ "thiserror 2.0.21",
+ "wincode-derive",
+]
+
+[[package]]
+name = "wincode-derive"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15ab90b719560d0fda79c74550ad1c948d17b118765942838055ebaf34d67071"
+dependencies = [
+ "darling 0.23.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/solana/Cargo.toml b/solana/Cargo.toml
new file mode 100644
index 00000000..6918ed50
--- /dev/null
+++ b/solana/Cargo.toml
@@ -0,0 +1,13 @@
+[workspace]
+members = ["programs/launch_pool", "runtime-tests"]
+resolver = "2"
+
+[profile.release]
+overflow-checks = true
+lto = "fat"
+codegen-units = 1
+
+[profile.release.build-override]
+opt-level = 3
+incremental = false
+codegen-units = 1
diff --git a/solana/SECURITY.md b/solana/SECURITY.md
new file mode 100644
index 00000000..d767d0ca
--- /dev/null
+++ b/solana/SECURITY.md
@@ -0,0 +1,38 @@
+# Solana launch pool security model
+
+This implementation is unaudited and not approved for mainnet funds. Follow the repository's private vulnerability reporting policy in [SECURITY.md](../SECURITY.md). Do not disclose an exploitable vulnerability in a public PR comment.
+
+## Trust and custody
+
+The original program holds token inventory and native SOL in per-pool PDAs. There is no external AMM, owner/admin instruction, LP withdrawal, pause, upgrade switch, migration, rescue, arbitrary CPI, or active-account close. All economic terms are fixed in preparation. Activation requires the exact executable's linked ProgramData to have no upgrade authority. Once active, an immutable defect cannot be patched and the website cannot pause direct users.
+
+The legacy SPL Token Program is the only token CPI target. Anchor's initialization macros require token-2022 helper code to compile, but instruction accounts are constrained to `Program` and legacy `Mint`/`TokenAccount`, so Token-2022 mints/extensions/hooks are not accepted. No Metaplex or other metadata CPI is used. The custom on-chain name/symbol/URI do not guarantee wallet metadata recognition or hosted-media availability.
+
+## Protected invariants
+
+- Supply is exactly 1 billion tokens at 6 decimals on activation, all in the vault. Mint authority is atomically revoked and freeze authority is absent. Later holder burns are allowed.
+- Accounted token inventory X remains within `(0, S]`; real SOL R excludes rent and fees; `X*(R+V) >= S*V`. V is virtual pricing only, never collateral or available liquidity.
+- Buy input is split into real reserves and fixed fees. Sell output plus fee is capped by real reserve solvency, not virtual balances. Slippage and slot expiry are enforced on-chain.
+- Actual vault balances must cover accounted obligations. Surplus donations neither affect price nor become claimable; they remain inaccessible because no recovery path exists.
+- Cumulative fee entitlements use u128 counters and overflow-safe proportional math. A claim can only pay the exact fixed recipient from the fee vault. No signature is needed to pay someone their own entitlement. Rent is protected and duplicate/aliased protocol recipients are rejected.
+- Cancelled preparation is a permanent tombstone, not a reusable nonce. Preparation has no auxiliary accounts, so cancelled preparation rent is not refunded.
+
+## Threats and limitations
+
+Review rounding manipulation, dust/near-empty inventory, maximum integer bounds, fee-claim ordering, malicious seed/account substitution, CPI privilege leakage, prefunded-PDA griefing, transaction atomicity, reinitialization, fabricated program data, external SPL transfers/burns, and failed or duplicated transaction ingestion. Host emulation is not a substitute for validator behavior.
+
+Economic safety is not guaranteed by immutability. A token starts with zero real SOL liquidity. Price/FDV based on V is not an exit-value guarantee. Slippage, sandwich/MEV ordering, declining demand, locked dust, creator-recipient trading incentives and arbitrary starting valuations require economic review and clear UI disclosure. The initial V bounds are mechanical, not investment guidance or endorsed fair valuations.
+
+Fee recipients are immutable. A recipient unable to receive a lamport transfer (for example runtime rent restrictions or incompatible account state) can lose access to its fees; others must remain able to claim independently. The protocol cannot rewrite destinations to repair mistakes. Applications should simulate and explain recipient compatibility, without claiming that present simulation proves future account availability.
+
+RPC/indexer/UI may be unavailable or malicious. Users must verify program ID, token mint, transaction bytes, recipients and minimum output. Publish a read/quote/build recovery SDK, preserve signatures until resolved and never silently rebuild a possibly-submitted transaction. Verify successful transaction origin, not only log text. Holders require full token-transfer coverage, not swap-event inference.
+
+## Release gates
+
+1. Freeze specification and mechanically bounded parameters; independent economic and security reviews of the exact commit, with findings resolved and retested.
+2. Reproducible modern SBF build, independent bytecode match and full validator/SBF acceptance matrix in [BUILD.md](BUILD.md), including maximum transaction/compute/stack measurements.
+3. Real wallet desktop/mobile tests, recipient compatibility, failure/reload recovery, durable indexer/finalization and EVM regression tests.
+4. Kevin's separately authorized deployment; verify executable/ProgramData linkage and binary. Authority revocation is irreversible and must be explicitly approved and independently verified. No automatic deployment or revocation is provided.
+5. Separately approved mainnet test budget and successful round trip/claims/index reconstruction before production UI enablement. The canary is a test budget, not an on-chain permission or TVL cap: once immutable, anyone can activate pools directly.
+
+No audit or deployment gate is cleared by merging this PR. A frontend rollback cannot recover compromised immutable funds.
diff --git a/solana/programs/launch_pool/Cargo.toml b/solana/programs/launch_pool/Cargo.toml
new file mode 100644
index 00000000..2a666fb1
--- /dev/null
+++ b/solana/programs/launch_pool/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "launch-pool"
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "lib"]
+name = "launch_pool"
+
+[features]
+# Exclude Anchor's separately managed on-chain IDL instruction surface. The SDK
+# and ABI are published with source, not controlled by an IDL authority account.
+default = ["no-idl"]
+cpi = ["no-entrypoint"]
+no-entrypoint = []
+no-idl = []
+no-log-ix-name = []
+anchor-debug = []
+idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
+
+[dependencies]
+anchor-lang = "=0.31.1"
+# Anchor's account-init macro references token_2022 helpers even for legacy
+# Token account types. No instruction accepts Token-2022 as the program ID.
+anchor-spl = { version = "=0.31.1", default-features = false, features = ["token", "token_2022", "token_2022_extensions"] }
+bincode = "=1.3.3"
+
+[dev-dependencies]
+serde_json = "=1.0.151"
diff --git a/solana/programs/launch_pool/src/lib.rs b/solana/programs/launch_pool/src/lib.rs
new file mode 100644
index 00000000..2d60b16d
--- /dev/null
+++ b/solana/programs/launch_pool/src/lib.rs
@@ -0,0 +1,774 @@
+#![allow(unexpected_cfgs)]
+// Anchor 0.31 generates a crate-level compatibility use of AccountInfo::realloc.
+// Keep all other warnings denied in CI; revisit this exception when upgrading Anchor.
+#![allow(deprecated)]
+use anchor_lang::prelude::*;
+// This pinned Anchor version reexports the legacy loader wire type. Its wire
+// format is deliberately checked below; upgrading the framework is a release review.
+#[allow(deprecated)]
+use anchor_lang::solana_program::{bpf_loader_upgradeable, program_option::COption};
+use anchor_lang::system_program::{self, Transfer as SolTransfer};
+use anchor_spl::token::spl_token::instruction::AuthorityType;
+use anchor_spl::token::{self, Mint, MintTo, SetAuthority, Token, TokenAccount, TransferChecked};
+
+pub mod math;
+use math::{DECIMALS, MAX_VIRTUAL_SOL, MIN_VIRTUAL_SOL, SUPPLY};
+
+// NONPRODUCTION compile/test fixture. A release must bind its real program ID
+// into source, SDK configuration and verified build manifest before deployment.
+declare_id!("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
+
+pub const PREPARED: u8 = 0;
+pub const ACTIVE: u8 = 1;
+pub const CANCELLED: u8 = 2;
+pub const MAX_RECIPIENTS: usize = 7;
+
+#[program]
+pub mod launch_pool {
+ use super::*;
+
+ pub fn prepare_launch(ctx: Context, args: PrepareArgs) -> Result<()> {
+ validate_terms(&args, &ctx.accounts.pool.key())?;
+ let pool = &mut ctx.accounts.pool;
+ pool.version = 1;
+ pool.status = PREPARED;
+ pool.bump = ctx.bumps.pool;
+ pool.nonce = args.nonce;
+ pool.creator = ctx.accounts.creator.key();
+ pool.rent_payer = ctx.accounts.payer.key();
+ pool.mint = Pubkey::find_program_address(&[b"mint", pool.key().as_ref()], &crate::ID).0;
+ pool.virtual_sol = args.virtual_sol;
+ pool.fee_bps = args.fee_bps;
+ pool.recipient_count = u8::try_from(args.recipients.len())
+ .map_err(|_| error!(PoolError::InvalidRecipients))?;
+ pool.recipients = [Recipient::default(); MAX_RECIPIENTS];
+ for (index, recipient) in args.recipients.iter().enumerate() {
+ pool.recipients[index] = Recipient {
+ address: recipient.address,
+ weight_bps: recipient.weight_bps,
+ paid: 0,
+ };
+ }
+ pool.token_reserve = 0;
+ pool.sol_reserve = 0;
+ pool.fees_earned = 0;
+ pool.sequence = 0;
+ pool.name = args.name;
+ pool.symbol = args.symbol;
+ pool.uri = args.uri;
+ pool.content_hash = args.content_hash;
+ emit!(Prepared {
+ pool: pool.key(),
+ creator: pool.creator,
+ nonce: pool.nonce
+ });
+ Ok(())
+ }
+
+ pub fn activate_launch(ctx: Context) -> Result<()> {
+ require!(
+ ctx.accounts.pool.status == PREPARED,
+ PoolError::InvalidStatus
+ );
+ verify_immutable(
+ &ctx.accounts.executable.to_account_info(),
+ &ctx.accounts.program_data.to_account_info(),
+ )?;
+ let pool_key = ctx.accounts.pool.key();
+ let authority_bump = [ctx.bumps.mint_authority];
+ let authority_seeds: &[&[u8]] = &[b"mint-authority", pool_key.as_ref(), &authority_bump];
+ let signers = &[authority_seeds];
+ require!(
+ ctx.accounts.mint.supply == 0 && ctx.accounts.mint.decimals == DECIMALS,
+ PoolError::InvalidMint
+ );
+ require!(
+ ctx.accounts.mint.mint_authority == COption::Some(ctx.accounts.mint_authority.key())
+ && ctx.accounts.mint.freeze_authority == COption::None,
+ PoolError::InvalidMint
+ );
+ validate_token_vault(
+ &ctx.accounts.token_vault,
+ &pool_key,
+ &ctx.accounts.mint.key(),
+ )?;
+ require!(
+ ctx.accounts.token_vault.amount == 0,
+ PoolError::InvalidVault
+ );
+ token::mint_to(
+ CpiContext::new_with_signer(
+ ctx.accounts.token_program.to_account_info(),
+ MintTo {
+ mint: ctx.accounts.mint.to_account_info(),
+ to: ctx.accounts.token_vault.to_account_info(),
+ authority: ctx.accounts.mint_authority.to_account_info(),
+ },
+ signers,
+ ),
+ SUPPLY,
+ )?;
+ token::set_authority(
+ CpiContext::new_with_signer(
+ ctx.accounts.token_program.to_account_info(),
+ SetAuthority {
+ current_authority: ctx.accounts.mint_authority.to_account_info(),
+ account_or_mint: ctx.accounts.mint.to_account_info(),
+ },
+ signers,
+ ),
+ AuthorityType::MintTokens,
+ None,
+ )?;
+ ctx.accounts.mint.reload()?;
+ ctx.accounts.token_vault.reload()?;
+ require!(
+ ctx.accounts.mint.supply == SUPPLY
+ && ctx.accounts.mint.mint_authority == COption::None
+ && ctx.accounts.mint.freeze_authority == COption::None,
+ PoolError::InvalidMint
+ );
+ require!(
+ ctx.accounts.token_vault.amount == SUPPLY,
+ PoolError::InvalidVault
+ );
+ validate_token_vault(
+ &ctx.accounts.token_vault,
+ &pool_key,
+ &ctx.accounts.mint.key(),
+ )?;
+ let rent_floor = ctx.accounts.rent.minimum_balance(SolVault::SPACE);
+ ctx.accounts.reserve.set_inner(SolVault {
+ pool: pool_key,
+ kind: 0,
+ rent_floor,
+ });
+ ctx.accounts.fees.set_inner(SolVault {
+ pool: pool_key,
+ kind: 1,
+ rent_floor,
+ });
+ ctx.accounts.pool.token_reserve = SUPPLY;
+ ctx.accounts.pool.status = ACTIVE;
+ check_sol_coverage(&ctx.accounts.reserve, 0)?;
+ check_sol_coverage(&ctx.accounts.fees, 0)?;
+ emit!(Activated {
+ pool: pool_key,
+ mint: ctx.accounts.mint.key(),
+ supply: SUPPLY
+ });
+ Ok(())
+ }
+
+ pub fn cancel_preparation(ctx: Context) -> Result<()> {
+ require!(
+ ctx.accounts.pool.status == PREPARED,
+ PoolError::InvalidStatus
+ );
+ // Never close: the permanent tombstone consumes this creator/nonce.
+ ctx.accounts.pool.status = CANCELLED;
+ emit!(Cancelled {
+ pool: ctx.accounts.pool.key()
+ });
+ Ok(())
+ }
+
+ pub fn buy_exact_in(
+ ctx: Context,
+ amount_in: u64,
+ minimum_out: u64,
+ expiry_slot: u64,
+ ) -> Result<()> {
+ let slot = validate_trade(ctx.accounts, expiry_slot)?;
+ let pool = &ctx.accounts.pool;
+ let quote = math::buy(
+ pool.token_reserve,
+ pool.sol_reserve,
+ pool.virtual_sol,
+ pool.fee_bps,
+ amount_in,
+ )?;
+ require!(quote.amount_out >= minimum_out, PoolError::Slippage);
+ let next_fees = pool
+ .fees_earned
+ .checked_add(u128::from(quote.fee))
+ .ok_or(PoolError::Arithmetic)?;
+ let next_sequence = pool.sequence.checked_add(1).ok_or(PoolError::Arithmetic)?;
+ sol_transfer(
+ &ctx.accounts.system_program,
+ &ctx.accounts.trader.to_account_info(),
+ &ctx.accounts.reserve.to_account_info(),
+ amount_in - quote.fee,
+ )?;
+ sol_transfer(
+ &ctx.accounts.system_program,
+ &ctx.accounts.trader.to_account_info(),
+ &ctx.accounts.fees.to_account_info(),
+ quote.fee,
+ )?;
+ let nonce = pool.nonce.to_le_bytes();
+ let bump = [pool.bump];
+ let seeds: &[&[u8]] = &[b"pool", pool.creator.as_ref(), &nonce, &bump];
+ token::transfer_checked(
+ CpiContext::new_with_signer(
+ ctx.accounts.token_program.to_account_info(),
+ TransferChecked {
+ from: ctx.accounts.token_vault.to_account_info(),
+ mint: ctx.accounts.mint.to_account_info(),
+ to: ctx.accounts.trader_token.to_account_info(),
+ authority: pool.to_account_info(),
+ },
+ &[seeds],
+ ),
+ quote.amount_out,
+ DECIMALS,
+ )?;
+ apply_trade(&mut ctx.accounts.pool, "e, next_fees, next_sequence);
+ ctx.accounts.token_vault.reload()?;
+ validate_balances(ctx.accounts)?;
+ emit!(Swap {
+ pool: ctx.accounts.pool.key(),
+ trader: ctx.accounts.trader.key(),
+ sequence: next_sequence,
+ is_buy: true,
+ amount_in,
+ amount_out: quote.amount_out,
+ fee: quote.fee,
+ token_reserve: quote.token_reserve,
+ sol_reserve: quote.sol_reserve,
+ slot
+ });
+ Ok(())
+ }
+
+ pub fn sell_exact_in(
+ ctx: Context,
+ amount_in: u64,
+ minimum_out: u64,
+ expiry_slot: u64,
+ ) -> Result<()> {
+ let slot = validate_trade(ctx.accounts, expiry_slot)?;
+ let pool = &ctx.accounts.pool;
+ let quote = math::sell(
+ pool.token_reserve,
+ pool.sol_reserve,
+ pool.virtual_sol,
+ pool.fee_bps,
+ amount_in,
+ )?;
+ require!(quote.amount_out >= minimum_out, PoolError::Slippage);
+ let next_fees = pool
+ .fees_earned
+ .checked_add(u128::from(quote.fee))
+ .ok_or(PoolError::Arithmetic)?;
+ let next_sequence = pool.sequence.checked_add(1).ok_or(PoolError::Arithmetic)?;
+ token::transfer_checked(
+ CpiContext::new(
+ ctx.accounts.token_program.to_account_info(),
+ TransferChecked {
+ from: ctx.accounts.trader_token.to_account_info(),
+ mint: ctx.accounts.mint.to_account_info(),
+ to: ctx.accounts.token_vault.to_account_info(),
+ authority: ctx.accounts.trader.to_account_info(),
+ },
+ ),
+ amount_in,
+ DECIMALS,
+ )?;
+ move_owned_lamports(
+ &ctx.accounts.reserve.to_account_info(),
+ &ctx.accounts.trader.to_account_info(),
+ quote.amount_out,
+ )?;
+ move_owned_lamports(
+ &ctx.accounts.reserve.to_account_info(),
+ &ctx.accounts.fees.to_account_info(),
+ quote.fee,
+ )?;
+ apply_trade(&mut ctx.accounts.pool, "e, next_fees, next_sequence);
+ ctx.accounts.token_vault.reload()?;
+ validate_balances(ctx.accounts)?;
+ emit!(Swap {
+ pool: ctx.accounts.pool.key(),
+ trader: ctx.accounts.trader.key(),
+ sequence: next_sequence,
+ is_buy: false,
+ amount_in,
+ amount_out: quote.amount_out,
+ fee: quote.fee,
+ token_reserve: quote.token_reserve,
+ sol_reserve: quote.sol_reserve,
+ slot
+ });
+ Ok(())
+ }
+
+ pub fn claim_fees(ctx: Context, recipient_index: u8) -> Result<()> {
+ let pool = &mut ctx.accounts.pool;
+ require!(pool.status == ACTIVE, PoolError::InvalidStatus);
+ let index = usize::from(recipient_index);
+ require!(
+ index < usize::from(pool.recipient_count),
+ PoolError::InvalidRecipients
+ );
+ let recipient = pool.recipients[index];
+ require_keys_eq!(
+ recipient.address,
+ ctx.accounts.recipient.key(),
+ PoolError::InvalidRecipients
+ );
+ require!(
+ !ctx.accounts.recipient.executable && ctx.accounts.recipient.owner != &crate::ID,
+ PoolError::InvalidRecipients
+ );
+ check_sol_coverage(&ctx.accounts.fees, pool.fee_liability()?)?;
+ let entitled = math::entitlement(pool.fees_earned, recipient.weight_bps)?;
+ let amount = u64::try_from(
+ entitled
+ .checked_sub(recipient.paid)
+ .ok_or(PoolError::Arithmetic)?,
+ )
+ .map_err(|_| error!(PoolError::Arithmetic))?;
+ require!(amount > 0, PoolError::ZeroAmount);
+ move_owned_lamports(
+ &ctx.accounts.fees.to_account_info(),
+ &ctx.accounts.recipient.to_account_info(),
+ amount,
+ )?;
+ pool.recipients[index].paid = entitled;
+ check_sol_coverage(&ctx.accounts.fees, pool.fee_liability()?)?;
+ emit!(FeesClaimed {
+ pool: pool.key(),
+ recipient: recipient.address,
+ amount,
+ cumulative_paid: entitled
+ });
+ Ok(())
+ }
+}
+
+#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
+pub struct PrepareArgs {
+ pub nonce: u64,
+ pub virtual_sol: u64,
+ pub fee_bps: u16,
+ pub name: String,
+ pub symbol: String,
+ pub uri: String,
+ pub content_hash: [u8; 32],
+ pub recipients: Vec,
+}
+
+#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
+pub struct RecipientInput {
+ pub address: Pubkey,
+ pub weight_bps: u16,
+}
+
+#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Default)]
+pub struct Recipient {
+ pub address: Pubkey,
+ pub weight_bps: u16,
+ pub paid: u128,
+}
+
+#[account]
+pub struct Pool {
+ pub version: u8,
+ pub status: u8,
+ pub bump: u8,
+ pub nonce: u64,
+ pub creator: Pubkey,
+ pub rent_payer: Pubkey,
+ pub mint: Pubkey,
+ pub virtual_sol: u64,
+ pub fee_bps: u16,
+ pub recipient_count: u8,
+ pub recipients: [Recipient; MAX_RECIPIENTS],
+ pub token_reserve: u64,
+ pub sol_reserve: u64,
+ pub fees_earned: u128,
+ pub sequence: u64,
+ pub name: String,
+ pub symbol: String,
+ pub uri: String,
+ pub content_hash: [u8; 32],
+}
+
+impl Pool {
+ pub const SPACE: usize =
+ 8 + 3 + 8 + 96 + 8 + 2 + 1 + 7 * 50 + 16 + 16 + 8 + 12 + 32 + 10 + 200 + 32;
+ pub fn fee_liability(&self) -> Result {
+ require!(
+ usize::from(self.recipient_count) <= MAX_RECIPIENTS,
+ PoolError::InvalidRecipients
+ );
+ let mut paid = 0u128;
+ for r in &self.recipients[..usize::from(self.recipient_count)] {
+ paid = paid.checked_add(r.paid).ok_or(PoolError::Arithmetic)?;
+ }
+ self.fees_earned
+ .checked_sub(paid)
+ .ok_or_else(|| error!(PoolError::Arithmetic))
+ }
+}
+
+#[account]
+pub struct SolVault {
+ pub pool: Pubkey,
+ pub kind: u8,
+ pub rent_floor: u64,
+}
+impl SolVault {
+ pub const SPACE: usize = 8 + 32 + 1 + 8;
+}
+
+#[derive(Accounts)]
+#[instruction(args: PrepareArgs)]
+pub struct PrepareLaunch<'info> {
+ pub creator: Signer<'info>,
+ #[account(mut)]
+ pub payer: Signer<'info>,
+ #[account(init, payer = payer, space = Pool::SPACE, seeds = [b"pool", creator.key().as_ref(), &args.nonce.to_le_bytes()], bump)]
+ pub pool: Box>,
+ pub system_program: Program<'info, System>,
+}
+
+#[derive(Accounts)]
+pub struct ActivateLaunch<'info> {
+ #[account(mut)]
+ pub creator: Signer<'info>,
+ #[account(mut, seeds = [b"pool", pool.creator.as_ref(), &pool.nonce.to_le_bytes()], bump = pool.bump, has_one = creator, constraint = pool.version == 1 @ PoolError::InvalidVersion, constraint = pool.status == PREPARED @ PoolError::InvalidStatus)]
+ pub pool: Box>,
+ #[account(init, payer = creator, seeds = [b"mint", pool.key().as_ref()], bump, mint::decimals = DECIMALS, mint::authority = mint_authority)]
+ pub mint: Box>,
+ #[account(init, payer = creator, seeds = [b"token", pool.key().as_ref()], bump, token::mint = mint, token::authority = pool)]
+ pub token_vault: Box>,
+ #[account(init, payer = creator, space = SolVault::SPACE, seeds = [b"reserve", pool.key().as_ref()], bump)]
+ pub reserve: Box>,
+ #[account(init, payer = creator, space = SolVault::SPACE, seeds = [b"fees", pool.key().as_ref()], bump)]
+ pub fees: Box>,
+ /// CHECK: only this seed-derived temporary authority can mint during activation.
+ #[account(seeds = [b"mint-authority", pool.key().as_ref()], bump)]
+ pub mint_authority: UncheckedAccount<'info>,
+ /// CHECK: exact executable and loader linkage are checked in verify_immutable.
+ pub executable: UncheckedAccount<'info>,
+ /// CHECK: exact linked ProgramData and revoked authority checked in verify_immutable.
+ pub program_data: UncheckedAccount<'info>,
+ pub token_program: Program<'info, Token>,
+ pub system_program: Program<'info, System>,
+ pub rent: Sysvar<'info, Rent>,
+}
+
+#[derive(Accounts)]
+pub struct CancelPreparation<'info> {
+ pub creator: Signer<'info>,
+ #[account(mut, seeds = [b"pool", pool.creator.as_ref(), &pool.nonce.to_le_bytes()], bump = pool.bump, has_one = creator, constraint = pool.version == 1 @ PoolError::InvalidVersion)]
+ pub pool: Box>,
+}
+
+#[derive(Accounts)]
+pub struct Trade<'info> {
+ #[account(mut)]
+ pub trader: Signer<'info>,
+ #[account(mut, seeds = [b"pool", pool.creator.as_ref(), &pool.nonce.to_le_bytes()], bump = pool.bump, has_one = mint, constraint = pool.version == 1 @ PoolError::InvalidVersion, constraint = pool.status == ACTIVE @ PoolError::InvalidStatus)]
+ pub pool: Box>,
+ #[account(seeds = [b"mint", pool.key().as_ref()], bump)]
+ pub mint: Box>,
+ #[account(mut, seeds = [b"token", pool.key().as_ref()], bump, token::mint = mint, token::authority = pool)]
+ pub token_vault: Box>,
+ #[account(mut, seeds = [b"reserve", pool.key().as_ref()], bump, has_one = pool, constraint = reserve.kind == 0 @ PoolError::InvalidVault)]
+ pub reserve: Box>,
+ #[account(mut, seeds = [b"fees", pool.key().as_ref()], bump, has_one = pool, constraint = fees.kind == 1 @ PoolError::InvalidVault)]
+ pub fees: Box>,
+ #[account(mut, token::mint = mint, token::authority = trader, constraint = trader_token.key() != token_vault.key() @ PoolError::AccountAlias)]
+ pub trader_token: Box>,
+ pub token_program: Program<'info, Token>,
+ pub system_program: Program<'info, System>,
+}
+
+#[derive(Accounts)]
+pub struct ClaimFees<'info> {
+ #[account(mut, seeds = [b"pool", pool.creator.as_ref(), &pool.nonce.to_le_bytes()], bump = pool.bump, constraint = pool.version == 1 @ PoolError::InvalidVersion)]
+ pub pool: Box>,
+ #[account(mut, seeds = [b"fees", pool.key().as_ref()], bump, has_one = pool, constraint = fees.kind == 1 @ PoolError::InvalidVault)]
+ pub fees: Box>,
+ /// CHECK: only the fixed stored recipient is accepted; never a caller-selected payout.
+ #[account(mut)]
+ pub recipient: UncheckedAccount<'info>,
+}
+
+pub fn validate_terms(args: &PrepareArgs, pool: &Pubkey) -> Result<()> {
+ require!(
+ (MIN_VIRTUAL_SOL..=MAX_VIRTUAL_SOL).contains(&args.virtual_sol),
+ PoolError::InvalidVirtualSol
+ );
+ require!(matches!(args.fee_bps, 0 | 100 | 300), PoolError::InvalidFee);
+ require!(
+ !args.name.trim().is_empty()
+ && args.name.len() <= 32
+ && !args.name.chars().any(char::is_control),
+ PoolError::InvalidMetadata
+ );
+ require!(
+ !args.symbol.trim().is_empty()
+ && args.symbol.len() <= 10
+ && !args.symbol.chars().any(char::is_control),
+ PoolError::InvalidMetadata
+ );
+ require!(
+ args.uri.len() <= 200 && !args.uri.chars().any(char::is_control),
+ PoolError::InvalidMetadata
+ );
+ if args.fee_bps == 0 {
+ require!(args.recipients.is_empty(), PoolError::InvalidRecipients);
+ return Ok(());
+ }
+ require!(
+ !args.recipients.is_empty() && args.recipients.len() <= MAX_RECIPIENTS,
+ PoolError::InvalidRecipients
+ );
+ let aliases: Vec = [
+ b"mint".as_slice(),
+ b"token",
+ b"reserve",
+ b"fees",
+ b"mint-authority",
+ ]
+ .iter()
+ .map(|seed| Pubkey::find_program_address(&[seed, pool.as_ref()], &crate::ID).0)
+ .collect();
+ let mut sum = 0u32;
+ for (i, r) in args.recipients.iter().enumerate() {
+ require!(
+ r.address != Pubkey::default()
+ && r.address != *pool
+ && !aliases.contains(&r.address)
+ && r.address != crate::ID,
+ PoolError::InvalidRecipients
+ );
+ require!(
+ r.weight_bps > 0
+ && r.weight_bps <= 10_000
+ && !args.recipients[..i]
+ .iter()
+ .any(|other| other.address == r.address),
+ PoolError::InvalidRecipients
+ );
+ sum = sum
+ .checked_add(u32::from(r.weight_bps))
+ .ok_or(PoolError::Arithmetic)?;
+ }
+ require!(sum == 10_000, PoolError::InvalidRecipients);
+ Ok(())
+}
+
+pub fn verify_immutable(executable: &AccountInfo, program_data: &AccountInfo) -> Result<()> {
+ require_keys_eq!(*executable.key, crate::ID, PoolError::MutableProgram);
+ require!(
+ executable.executable
+ && executable.owner == &bpf_loader_upgradeable::ID
+ && program_data.owner == &bpf_loader_upgradeable::ID,
+ PoolError::MutableProgram
+ );
+ let expected_data =
+ Pubkey::find_program_address(&[crate::ID.as_ref()], &bpf_loader_upgradeable::ID).0;
+ require_keys_eq!(*program_data.key, expected_data, PoolError::MutableProgram);
+ let code_data = executable.try_borrow_data()?;
+ require!(code_data.len() == 36, PoolError::MutableProgram);
+ let state: bpf_loader_upgradeable::UpgradeableLoaderState =
+ bincode::deserialize(&code_data).map_err(|_| error!(PoolError::MutableProgram))?;
+ match state {
+ bpf_loader_upgradeable::UpgradeableLoaderState::Program {
+ programdata_address,
+ } => require_keys_eq!(
+ programdata_address,
+ expected_data,
+ PoolError::MutableProgram
+ ),
+ _ => return err!(PoolError::MutableProgram),
+ }
+ let data = program_data.try_borrow_data()?;
+ require!(data.len() >= 45, PoolError::MutableProgram);
+ let state: bpf_loader_upgradeable::UpgradeableLoaderState =
+ bincode::deserialize(&data).map_err(|_| error!(PoolError::MutableProgram))?;
+ match state {
+ bpf_loader_upgradeable::UpgradeableLoaderState::ProgramData {
+ upgrade_authority_address: None,
+ ..
+ } => Ok(()),
+ _ => err!(PoolError::MutableProgram),
+ }
+}
+
+fn validate_token_vault(vault: &TokenAccount, pool: &Pubkey, mint: &Pubkey) -> Result<()> {
+ require!(
+ vault.owner == *pool
+ && vault.mint == *mint
+ && vault.delegate == COption::None
+ && vault.close_authority == COption::None
+ && vault.is_native == COption::None,
+ PoolError::InvalidVault
+ );
+ Ok(())
+}
+fn check_sol_coverage(vault: &Account, liability: u128) -> Result<()> {
+ let needed = liability
+ .checked_add(u128::from(vault.rent_floor))
+ .ok_or(PoolError::Arithmetic)?;
+ require!(
+ u128::from(vault.to_account_info().lamports()) >= needed,
+ PoolError::Insolvent
+ );
+ Ok(())
+}
+fn validate_balances(accounts: &Trade) -> Result<()> {
+ require!(
+ accounts.token_vault.amount >= accounts.pool.token_reserve,
+ PoolError::Insolvent
+ );
+ check_sol_coverage(&accounts.reserve, u128::from(accounts.pool.sol_reserve))?;
+ check_sol_coverage(&accounts.fees, accounts.pool.fee_liability()?)?;
+ Ok(())
+}
+fn validate_trade(accounts: &Trade, expiry_slot: u64) -> Result {
+ let slot = Clock::get()?.slot;
+ require!(slot <= expiry_slot, PoolError::Expired);
+ require!(
+ accounts.trader.owner != &crate::ID && !accounts.trader.executable,
+ PoolError::AccountAlias
+ );
+ require!(
+ accounts.mint.mint_authority == COption::None
+ && accounts.mint.freeze_authority == COption::None
+ && accounts.mint.decimals == DECIMALS
+ && accounts.mint.supply <= SUPPLY,
+ PoolError::InvalidMint
+ );
+ validate_token_vault(
+ &accounts.token_vault,
+ &accounts.pool.key(),
+ &accounts.mint.key(),
+ )?;
+ validate_balances(accounts)?;
+ Ok(slot)
+}
+fn apply_trade(pool: &mut Account, quote: &math::Quote, fees: u128, sequence: u64) {
+ pool.token_reserve = quote.token_reserve;
+ pool.sol_reserve = quote.sol_reserve;
+ pool.fees_earned = fees;
+ pool.sequence = sequence;
+}
+fn sol_transfer<'info>(
+ program: &Program<'info, System>,
+ from: &AccountInfo<'info>,
+ to: &AccountInfo<'info>,
+ amount: u64,
+) -> Result<()> {
+ if amount > 0 {
+ system_program::transfer(
+ CpiContext::new(
+ program.to_account_info(),
+ SolTransfer {
+ from: from.clone(),
+ to: to.clone(),
+ },
+ ),
+ amount,
+ )?;
+ }
+ Ok(())
+}
+fn move_owned_lamports(from: &AccountInfo, to: &AccountInfo, amount: u64) -> Result<()> {
+ require!(
+ from.owner == &crate::ID && from.key != to.key,
+ PoolError::AccountAlias
+ );
+ let remaining = from
+ .lamports()
+ .checked_sub(amount)
+ .ok_or(PoolError::Insolvent)?;
+ let received = to
+ .lamports()
+ .checked_add(amount)
+ .ok_or(PoolError::Arithmetic)?;
+ **from.try_borrow_mut_lamports()? = remaining;
+ **to.try_borrow_mut_lamports()? = received;
+ Ok(())
+}
+
+#[event]
+pub struct Prepared {
+ pub pool: Pubkey,
+ pub creator: Pubkey,
+ pub nonce: u64,
+}
+#[event]
+pub struct Activated {
+ pub pool: Pubkey,
+ pub mint: Pubkey,
+ pub supply: u64,
+}
+#[event]
+pub struct Cancelled {
+ pub pool: Pubkey,
+}
+#[event]
+pub struct Swap {
+ pub pool: Pubkey,
+ pub trader: Pubkey,
+ pub sequence: u64,
+ pub is_buy: bool,
+ pub amount_in: u64,
+ pub amount_out: u64,
+ pub fee: u64,
+ pub token_reserve: u64,
+ pub sol_reserve: u64,
+ pub slot: u64,
+}
+#[event]
+pub struct FeesClaimed {
+ pub pool: Pubkey,
+ pub recipient: Pubkey,
+ pub amount: u64,
+ pub cumulative_paid: u128,
+}
+
+#[error_code]
+pub enum PoolError {
+ #[msg("Arithmetic limit exceeded")]
+ Arithmetic,
+ #[msg("Invalid immutable fee tier")]
+ InvalidFee,
+ #[msg("Invalid fixed recipients")]
+ InvalidRecipients,
+ #[msg("Invalid token inventory")]
+ InvalidInventory,
+ #[msg("Virtual SOL is outside the supported range")]
+ InvalidVirtualSol,
+ #[msg("Actual reserves do not cover accounted obligations")]
+ Insolvent,
+ #[msg("Trade or claim amount rounds to zero")]
+ ZeroAmount,
+ #[msg("Operation is not allowed in this pool state")]
+ InvalidStatus,
+ #[msg("Invalid mint authority, supply, or decimals")]
+ InvalidMint,
+ #[msg("Invalid custody vault")]
+ InvalidVault,
+ #[msg("Minimum output not met")]
+ Slippage,
+ #[msg("Protocol account aliases are not permitted")]
+ AccountAlias,
+ #[msg("Unsupported pool version")]
+ InvalidVersion,
+ #[msg("Invalid or oversized metadata")]
+ InvalidMetadata,
+ #[msg("Activation requires this exact program to be immutable")]
+ MutableProgram,
+ #[msg("Quote expired at its maximum slot")]
+ Expired,
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/solana/programs/launch_pool/src/math.rs b/solana/programs/launch_pool/src/math.rs
new file mode 100644
index 00000000..304f55a7
--- /dev/null
+++ b/solana/programs/launch_pool/src/math.rs
@@ -0,0 +1,177 @@
+//! Original checked integer curve and cumulative fee arithmetic. No floating point.
+use crate::PoolError;
+use anchor_lang::prelude::*;
+
+pub const SUPPLY: u64 = 1_000_000_000_000_000;
+pub const DECIMALS: u8 = 6;
+pub const MIN_VIRTUAL_SOL: u64 = 1_000_000_000;
+pub const MAX_VIRTUAL_SOL: u64 = 1_000_000_000_000_000;
+pub const BPS: u128 = 10_000;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Quote {
+ pub amount_out: u64,
+ pub fee: u64,
+ pub token_reserve: u64,
+ pub sol_reserve: u64,
+}
+
+pub fn fee(amount: u64, fee_bps: u16) -> Result {
+ require!(matches!(fee_bps, 0 | 100 | 300), PoolError::InvalidFee);
+ let product = u128::from(amount) * u128::from(fee_bps);
+ let result = product / BPS + u128::from(product % BPS != 0);
+ u64::try_from(result).map_err(|_| error!(PoolError::Arithmetic))
+}
+
+pub fn entitlement(earned: u128, weight: u16) -> Result {
+ require!(weight > 0 && weight <= 10_000, PoolError::InvalidRecipients);
+ // Decompose before multiplying: the result never exceeds earned, even at u128::MAX.
+ (earned / BPS)
+ .checked_mul(u128::from(weight))
+ .and_then(|n| n.checked_add((earned % BPS) * u128::from(weight) / BPS))
+ .ok_or_else(|| error!(PoolError::Arithmetic))
+}
+
+pub fn invariant(x: u64, r: u64, v: u64) -> Result<()> {
+ require!(x > 0 && x <= SUPPLY, PoolError::InvalidInventory);
+ require!(
+ (MIN_VIRTUAL_SOL..=MAX_VIRTUAL_SOL).contains(&v),
+ PoolError::InvalidVirtualSol
+ );
+ let total = r.checked_add(v).ok_or(PoolError::Arithmetic)?;
+ require!(
+ u128::from(x) * u128::from(total) >= u128::from(SUPPLY) * u128::from(v),
+ PoolError::Insolvent
+ );
+ Ok(())
+}
+
+pub fn buy(x: u64, r: u64, v: u64, fee_bps: u16, input: u64) -> Result {
+ invariant(x, r, v)?;
+ require!(input > 0, PoolError::ZeroAmount);
+ let fee = fee(input, fee_bps)?;
+ let net = input.checked_sub(fee).ok_or(PoolError::Arithmetic)?;
+ require!(net > 0, PoolError::ZeroAmount);
+ let denominator = r
+ .checked_add(v)
+ .and_then(|n| n.checked_add(net))
+ .ok_or(PoolError::Arithmetic)?;
+ let out = u64::try_from(u128::from(x) * u128::from(net) / u128::from(denominator))
+ .map_err(|_| error!(PoolError::Arithmetic))?;
+ require!(out > 0 && out < x, PoolError::ZeroAmount);
+ let next = Quote {
+ amount_out: out,
+ fee,
+ token_reserve: x - out,
+ sol_reserve: r.checked_add(net).ok_or(PoolError::Arithmetic)?,
+ };
+ invariant(next.token_reserve, next.sol_reserve, v)?;
+ Ok(next)
+}
+
+pub fn sell(x: u64, r: u64, v: u64, fee_bps: u16, input: u64) -> Result {
+ invariant(x, r, v)?;
+ require!(
+ input > 0 && input <= SUPPLY - x,
+ PoolError::InvalidInventory
+ );
+ let total = r.checked_add(v).ok_or(PoolError::Arithmetic)?;
+ let next_x = x.checked_add(input).ok_or(PoolError::Arithmetic)?;
+ let gross = u64::try_from(u128::from(total) * u128::from(input) / u128::from(next_x))
+ .map_err(|_| error!(PoolError::Arithmetic))?;
+ require!(gross <= r, PoolError::Insolvent);
+ let fee = fee(gross, fee_bps)?;
+ let out = gross.checked_sub(fee).ok_or(PoolError::Arithmetic)?;
+ require!(out > 0, PoolError::ZeroAmount);
+ let next = Quote {
+ amount_out: out,
+ fee,
+ token_reserve: next_x,
+ sol_reserve: r - gross,
+ };
+ invariant(next.token_reserve, next.sol_reserve, v)?;
+ Ok(next)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn published_round_trip() {
+ let a = buy(SUPPLY, 0, 30_000_000_000, 100, 1_000_000_000).unwrap();
+ assert_eq!(a.amount_out, 31_945_788_964_181);
+ assert_eq!(a.fee, 10_000_000);
+ let b = sell(
+ a.token_reserve,
+ a.sol_reserve,
+ 30_000_000_000,
+ 100,
+ a.amount_out,
+ )
+ .unwrap();
+ assert_eq!(b.amount_out, 980_099_999);
+ assert_eq!(b.sol_reserve, 1);
+ }
+ #[test]
+ fn ceilings_and_lifetime_boundaries() {
+ assert_eq!(fee(1, 100).unwrap(), 1);
+ assert_eq!(fee(u64::MAX, 300).unwrap(), 553_402_322_211_286_549);
+ assert_eq!(entitlement(u128::MAX, 10_000).unwrap(), u128::MAX);
+ let allocated = [3333, 3333, 3334]
+ .iter()
+ .map(|w| entitlement(u128::MAX, *w).unwrap())
+ .sum::();
+ assert!(u128::MAX - allocated < 3);
+ assert!(fee(10, 101).is_err());
+ assert!(entitlement(100, 0).is_err());
+ }
+ #[test]
+ fn rejects_extreme_invalid_states() {
+ assert!(buy(SUPPLY, 0, MIN_VIRTUAL_SOL, 0, u64::MAX).is_err());
+ assert!(buy(0, 0, MIN_VIRTUAL_SOL, 0, 1).is_err());
+ assert!(buy(SUPPLY, 0, MIN_VIRTUAL_SOL - 1, 0, 1).is_err());
+ assert!(buy(SUPPLY, 0, MIN_VIRTUAL_SOL, 100, 1).is_err());
+ assert!(sell(SUPPLY, 0, MIN_VIRTUAL_SOL, 0, 1).is_err());
+ assert!(sell(SUPPLY - 100, 0, MIN_VIRTUAL_SOL, 0, 1).is_err());
+ }
+ #[test]
+ fn randomized_conservation_and_round_trips() {
+ let mut seed = 29103u64;
+ for rate in [0, 100, 300] {
+ for v in [MIN_VIRTUAL_SOL, 30_000_000_000, MAX_VIRTUAL_SOL] {
+ let (mut x, mut r, mut circulating, mut paid_in, mut paid_out, mut fees) =
+ (SUPPLY, 0u64, 0u64, 0u128, 0u128, 0u128);
+ for _ in 0..12_000 {
+ seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
+ if circulating == 0 || seed & 1 == 0 {
+ let input = seed % 1_000_000_000 + 1;
+ if let Ok(q) = buy(x, r, v, rate, input) {
+ let reverse =
+ sell(q.token_reserve, q.sol_reserve, v, rate, q.amount_out);
+ if let Ok(reverse) = reverse {
+ assert!(reverse.amount_out <= input);
+ }
+ paid_in += u128::from(input);
+ circulating += q.amount_out;
+ fees += u128::from(q.fee);
+ x = q.token_reserve;
+ r = q.sol_reserve;
+ }
+ } else {
+ let input = seed % circulating + 1;
+ if let Ok(q) = sell(x, r, v, rate, input) {
+ paid_out += u128::from(q.amount_out);
+ circulating -= input;
+ fees += u128::from(q.fee);
+ x = q.token_reserve;
+ r = q.sol_reserve;
+ }
+ }
+ assert_eq!(x + circulating, SUPPLY);
+ assert_eq!(paid_in - paid_out, u128::from(r) + fees);
+ invariant(x, r, v).unwrap();
+ }
+ }
+ }
+ }
+}
diff --git a/solana/programs/launch_pool/src/tests.rs b/solana/programs/launch_pool/src/tests.rs
new file mode 100644
index 00000000..024fd77d
--- /dev/null
+++ b/solana/programs/launch_pool/src/tests.rs
@@ -0,0 +1,1072 @@
+//! Host instruction/constraint tests. These execute Anchor's generated dispatch
+//! and account validation, not an SBF VM. Full validator/SBF tests are a release gate.
+use super::*;
+use anchor_lang::solana_program::{
+ instruction::Instruction,
+ program_error::ProgramError,
+ program_pack::Pack,
+ program_stubs::{set_syscall_stubs, SyscallStubs},
+ system_instruction::SystemInstruction,
+};
+use anchor_lang::{AccountSerialize, InstructionData};
+use anchor_spl::token::spl_token::state::{Account as SplAccount, AccountState, Mint as SplMint};
+use std::sync::Mutex;
+
+static STUB_LOCK: Mutex<()> = Mutex::new(());
+
+/// A deliberately limited host harness: real SPL TransferChecked processing and
+/// checked native SOL transfer, with PDA signer derivation. No account creation,
+/// SBF metering, transaction rollback, rent-state checks or validator semantics.
+struct TransferStubs;
+impl SyscallStubs for TransferStubs {
+ fn sol_get_clock_sysvar(&self, pointer: *mut u8) -> u64 {
+ // Runtime supplies a writable Clock-sized buffer to this documented stub.
+ unsafe {
+ std::ptr::write(
+ pointer.cast::(),
+ Clock {
+ slot: 100,
+ ..Clock::default()
+ },
+ );
+ }
+ 0
+ }
+ fn sol_invoke_signed(
+ &self,
+ ix: &Instruction,
+ infos: &[AccountInfo],
+ signers: &[&[&[u8]]],
+ ) -> anchor_lang::solana_program::entrypoint::ProgramResult {
+ let pdas = signers
+ .iter()
+ .map(|seeds| Pubkey::create_program_address(seeds, &crate::ID))
+ .collect::, _>>()
+ .map_err(|_| ProgramError::InvalidSeeds)?;
+ let mut inner = Vec::new();
+ for meta in &ix.accounts {
+ let mut info = infos
+ .iter()
+ .find(|a| a.key == &meta.pubkey)
+ .ok_or(ProgramError::NotEnoughAccountKeys)?
+ .clone();
+ if meta.is_writable && !info.is_writable {
+ return Err(ProgramError::InvalidAccountData);
+ }
+ if pdas.contains(info.key) {
+ info.is_signer = true;
+ }
+ if meta.is_signer && !info.is_signer {
+ return Err(ProgramError::MissingRequiredSignature);
+ }
+ inner.push(info);
+ }
+ if ix.program_id == token::ID {
+ return token::spl_token::processor::Processor::process(&token::ID, &inner, &ix.data);
+ }
+ if ix.program_id == system_program::ID {
+ match bincode::deserialize::(&ix.data)
+ .map_err(|_| ProgramError::InvalidInstructionData)?
+ {
+ SystemInstruction::Transfer { lamports } => {
+ if inner.len() != 2
+ || inner[0].owner != &system_program::ID
+ || !inner[0].data_is_empty()
+ || !inner[0].is_signer
+ {
+ return Err(ProgramError::InvalidAccountData);
+ }
+ let remaining = inner[0]
+ .lamports()
+ .checked_sub(lamports)
+ .ok_or(ProgramError::InsufficientFunds)?;
+ let received = inner[1]
+ .lamports()
+ .checked_add(lamports)
+ .ok_or(ProgramError::ArithmeticOverflow)?;
+ **inner[0].try_borrow_mut_lamports()? = remaining;
+ **inner[1].try_borrow_mut_lamports()? = received;
+ return Ok(());
+ }
+ _ => return Err(ProgramError::InvalidInstructionData),
+ }
+ }
+ Err(ProgramError::IncorrectProgramId)
+ }
+}
+
+struct RestoreStubs(Option>);
+impl Drop for RestoreStubs {
+ fn drop(&mut self) {
+ if let Some(previous) = self.0.take() {
+ set_syscall_stubs(previous);
+ }
+ }
+}
+
+fn packed(value: T) -> Vec {
+ let mut data = vec![0u8; T::LEN];
+ T::pack(value, &mut data).unwrap();
+ data
+}
+
+fn trade_fixture() -> Vec> {
+ let (key, pool) = fixture_pool(ACTIVE);
+ let trader = Pubkey::new_unique();
+ let vault = Pubkey::find_program_address(&[b"token", key.as_ref()], &crate::ID).0;
+ let reserve = Pubkey::find_program_address(&[b"reserve", key.as_ref()], &crate::ID).0;
+ let fees = Pubkey::find_program_address(&[b"fees", key.as_ref()], &crate::ID).0;
+ let token_state = |owner, amount| SplAccount {
+ mint: pool.mint,
+ owner,
+ amount,
+ delegate: COption::None,
+ state: AccountState::Initialized,
+ is_native: COption::None,
+ delegated_amount: 0,
+ close_authority: COption::None,
+ };
+ vec![
+ account(
+ trader,
+ system_program::ID,
+ vec![],
+ 100_000_000_000,
+ true,
+ true,
+ false,
+ ),
+ pool_account(key, &pool),
+ account(
+ pool.mint,
+ token::ID,
+ packed(SplMint {
+ mint_authority: COption::None,
+ supply: SUPPLY,
+ decimals: DECIMALS,
+ is_initialized: true,
+ freeze_authority: COption::None,
+ }),
+ 1_000_000,
+ false,
+ false,
+ false,
+ ),
+ account(
+ vault,
+ token::ID,
+ packed(token_state(key, SUPPLY)),
+ 1_000_000,
+ false,
+ true,
+ false,
+ ),
+ account(
+ reserve,
+ crate::ID,
+ serialized(
+ &SolVault {
+ pool: key,
+ kind: 0,
+ rent_floor: 1000,
+ },
+ SolVault::SPACE,
+ ),
+ 1000,
+ false,
+ true,
+ false,
+ ),
+ account(
+ fees,
+ crate::ID,
+ serialized(
+ &SolVault {
+ pool: key,
+ kind: 1,
+ rent_floor: 1000,
+ },
+ SolVault::SPACE,
+ ),
+ 1000,
+ false,
+ true,
+ false,
+ ),
+ account(
+ Pubkey::new_unique(),
+ token::ID,
+ packed(token_state(trader, 0)),
+ 1_000_000,
+ false,
+ true,
+ false,
+ ),
+ account(
+ token::ID,
+ bpf_loader_upgradeable::ID,
+ vec![],
+ 1,
+ false,
+ false,
+ true,
+ ),
+ account(
+ system_program::ID,
+ bpf_loader_upgradeable::ID,
+ vec![],
+ 1,
+ false,
+ false,
+ true,
+ ),
+ ]
+}
+
+#[test]
+fn real_spl_cpi_host_buy_sell_conserves_and_never_spends_virtual_sol() {
+ let _lock = STUB_LOCK.lock().unwrap();
+ let _restore = RestoreStubs(Some(set_syscall_stubs(Box::new(TransferStubs))));
+ let accounts = trade_fixture();
+ execute(
+ accounts.clone(),
+ crate::instruction::BuyExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out: 31_945_788_964_181,
+ expiry_slot: 100,
+ }
+ .data(),
+ )
+ .unwrap();
+ let tokens = SplAccount::unpack(&accounts[6].try_borrow_data().unwrap())
+ .unwrap()
+ .amount;
+ assert_eq!(tokens, 31_945_788_964_181);
+ assert_eq!(accounts[0].lamports(), 99_000_000_000);
+ assert_eq!(accounts[4].lamports(), 990_001_000);
+ assert_eq!(accounts[5].lamports(), 10_001_000);
+ execute(
+ accounts.clone(),
+ crate::instruction::SellExactIn {
+ amount_in: tokens,
+ minimum_out: 980_099_999,
+ expiry_slot: 100,
+ }
+ .data(),
+ )
+ .unwrap();
+ assert_eq!(
+ SplAccount::unpack(&accounts[6].try_borrow_data().unwrap())
+ .unwrap()
+ .amount,
+ 0
+ );
+ assert_eq!(
+ SplAccount::unpack(&accounts[3].try_borrow_data().unwrap())
+ .unwrap()
+ .amount,
+ SUPPLY
+ );
+ assert_eq!(accounts[0].lamports(), 99_980_099_999);
+ assert_eq!(accounts[4].lamports(), 1001);
+ assert_eq!(accounts[5].lamports(), 19_901_000);
+ let pool = Pool::try_deserialize(&mut accounts[1].try_borrow_data().unwrap().as_ref()).unwrap();
+ assert_eq!(pool.sequence, 2);
+ assert_eq!(pool.fees_earned, 19_900_000);
+ assert_eq!(pool.sol_reserve, 1);
+}
+
+#[test]
+fn trade_constraints_slippage_expiry_authority_and_substitution_reject_before_cpi() {
+ let _lock = STUB_LOCK.lock().unwrap();
+ let _restore = RestoreStubs(Some(set_syscall_stubs(Box::new(TransferStubs))));
+ for mode in 0..8 {
+ let mut accounts = trade_fixture();
+ let mut expiry_slot = 100;
+ let mut minimum_out = 1;
+ match mode {
+ 0 => expiry_slot = 99,
+ 1 => minimum_out = SUPPLY,
+ 2 => accounts[0].is_signer = false,
+ 3 => {
+ accounts[7] = account(
+ Pubkey::new_unique(),
+ bpf_loader_upgradeable::ID,
+ vec![],
+ 1,
+ false,
+ false,
+ true,
+ )
+ }
+ 4 => accounts.swap(4, 5),
+ 5 => accounts[6] = accounts[3].clone(),
+ 6 => {
+ let mut mint = SplMint::unpack(&accounts[2].try_borrow_data().unwrap()).unwrap();
+ mint.mint_authority = COption::Some(Pubkey::new_unique());
+ SplMint::pack(mint, &mut accounts[2].try_borrow_mut_data().unwrap()).unwrap();
+ }
+ 7 => {
+ let mut vault =
+ SplAccount::unpack(&accounts[3].try_borrow_data().unwrap()).unwrap();
+ vault.delegate = COption::Some(Pubkey::new_unique());
+ SplAccount::pack(vault, &mut accounts[3].try_borrow_mut_data().unwrap()).unwrap();
+ }
+ _ => unreachable!(),
+ }
+ let before_sol = accounts[0].lamports();
+ assert!(
+ execute(
+ accounts.clone(),
+ crate::instruction::BuyExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out,
+ expiry_slot
+ }
+ .data()
+ )
+ .is_err(),
+ "mode {mode}"
+ );
+ assert_eq!(accounts[0].lamports(), before_sol);
+ }
+}
+
+#[test]
+fn donated_sol_and_tokens_are_not_priced_or_claimable() {
+ let _lock = STUB_LOCK.lock().unwrap();
+ let _restore = RestoreStubs(Some(set_syscall_stubs(Box::new(TransferStubs))));
+ let accounts = trade_fixture();
+ execute(
+ accounts.clone(),
+ crate::instruction::BuyExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out: 1,
+ expiry_slot: 100,
+ }
+ .data(),
+ )
+ .unwrap();
+ // Ordinary unsolicited transfers represented as external balance changes.
+ **accounts[4].try_borrow_mut_lamports().unwrap() += 5_000_000_000;
+ **accounts[5].try_borrow_mut_lamports().unwrap() += 1_000_000;
+ let mut trader = SplAccount::unpack(&accounts[6].try_borrow_data().unwrap()).unwrap();
+ let donated = 1_000_000;
+ trader.amount -= donated;
+ let mut vault = SplAccount::unpack(&accounts[3].try_borrow_data().unwrap()).unwrap();
+ vault.amount += donated;
+ SplAccount::pack(trader, &mut accounts[6].try_borrow_mut_data().unwrap()).unwrap();
+ SplAccount::pack(vault, &mut accounts[3].try_borrow_mut_data().unwrap()).unwrap();
+ let before =
+ Pool::try_deserialize(&mut accounts[1].try_borrow_data().unwrap().as_ref()).unwrap();
+ let expected = math::buy(
+ before.token_reserve,
+ before.sol_reserve,
+ before.virtual_sol,
+ before.fee_bps,
+ 1_000_000_000,
+ )
+ .unwrap();
+ execute(
+ accounts.clone(),
+ crate::instruction::BuyExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out: expected.amount_out,
+ expiry_slot: 100,
+ }
+ .data(),
+ )
+ .unwrap();
+ let after =
+ Pool::try_deserialize(&mut accounts[1].try_borrow_data().unwrap().as_ref()).unwrap();
+ assert_eq!(after.sol_reserve, expected.sol_reserve);
+ assert_eq!(
+ accounts[4].lamports(),
+ 1000 + expected.sol_reserve + 5_000_000_000
+ );
+ assert_eq!(
+ SplAccount::unpack(&accounts[3].try_borrow_data().unwrap())
+ .unwrap()
+ .amount,
+ after.token_reserve + donated
+ );
+ assert_eq!(
+ accounts[5].lamports(),
+ 1000 + u64::try_from(after.fees_earned).unwrap() + 1_000_000
+ );
+}
+
+fn account(
+ key: Pubkey,
+ owner: Pubkey,
+ data: Vec,
+ lamports: u64,
+ signer: bool,
+ writable: bool,
+ executable: bool,
+) -> AccountInfo<'static> {
+ AccountInfo::new(
+ Box::leak(Box::new(key)),
+ signer,
+ writable,
+ Box::leak(Box::new(lamports)),
+ Box::leak(data.into_boxed_slice()),
+ Box::leak(Box::new(owner)),
+ executable,
+ 0,
+ )
+}
+fn serialized(value: &T, size: usize) -> Vec {
+ let mut bytes = Vec::new();
+ value.try_serialize(&mut bytes).unwrap();
+ assert!(bytes.len() <= size);
+ bytes.resize(size, 0);
+ bytes
+}
+fn fixture_pool(status: u8) -> (Pubkey, Pool) {
+ let creator = Pubkey::new_unique();
+ let nonce = 91u64;
+ let (key, bump) = Pubkey::find_program_address(
+ &[b"pool", creator.as_ref(), &nonce.to_le_bytes()],
+ &crate::ID,
+ );
+ let mint = Pubkey::find_program_address(&[b"mint", key.as_ref()], &crate::ID).0;
+ let mut recipients = [Recipient::default(); 7];
+ recipients[0] = Recipient {
+ address: creator,
+ weight_bps: 10_000,
+ paid: 0,
+ };
+ (
+ key,
+ Pool {
+ version: 1,
+ status,
+ bump,
+ nonce,
+ creator,
+ rent_payer: creator,
+ mint,
+ virtual_sol: 30_000_000_000,
+ fee_bps: 100,
+ recipient_count: 1,
+ recipients,
+ token_reserve: SUPPLY,
+ sol_reserve: 0,
+ fees_earned: 0,
+ sequence: 0,
+ name: "Launch".into(),
+ symbol: "L".into(),
+ uri: "https://example.invalid/token.json".into(),
+ content_hash: [0; 32],
+ },
+ )
+}
+fn pool_account(key: Pubkey, pool: &Pool) -> AccountInfo<'static> {
+ account(
+ key,
+ crate::ID,
+ serialized(pool, Pool::SPACE),
+ 100_000_000,
+ false,
+ true,
+ false,
+ )
+}
+fn execute(
+ accounts: Vec>,
+ data: Vec,
+) -> anchor_lang::solana_program::entrypoint::ProgramResult {
+ // Anchor entry ties the slice and account lifetimes. Leaks are bounded fixtures.
+ crate::entry(&crate::ID, Box::leak(accounts.into_boxed_slice()), &data)
+}
+fn terms() -> PrepareArgs {
+ PrepareArgs {
+ nonce: 1,
+ virtual_sol: MIN_VIRTUAL_SOL,
+ fee_bps: 100,
+ name: "Launch".into(),
+ symbol: "TOKEN".into(),
+ uri: String::new(),
+ content_hash: [0; 32],
+ recipients: vec![RecipientInput {
+ address: Pubkey::new_unique(),
+ weight_bps: 10_000,
+ }],
+ }
+}
+
+#[test]
+fn canonical_terms_and_metadata_are_bounded() {
+ let pool = Pubkey::new_unique();
+ let mut args = terms();
+ validate_terms(&args, &pool).unwrap();
+ args.name = "é".repeat(17);
+ assert!(validate_terms(&args, &pool).is_err());
+ args.name = "Good".into();
+ args.symbol = "A\nB".into();
+ assert!(validate_terms(&args, &pool).is_err());
+ args.symbol = "OK".into();
+ args.uri = "x".repeat(201);
+ assert!(validate_terms(&args, &pool).is_err());
+ args.uri.clear();
+ args.virtual_sol = MAX_VIRTUAL_SOL + 1;
+ assert!(validate_terms(&args, &pool).is_err());
+}
+#[test]
+fn recipient_aliases_duplicates_and_weights_rejected() {
+ let pool = Pubkey::new_unique();
+ let mut args = terms();
+ args.recipients[0].address = pool;
+ assert!(validate_terms(&args, &pool).is_err());
+ for seed in [
+ b"mint".as_slice(),
+ b"token",
+ b"fees",
+ b"reserve",
+ b"mint-authority",
+ ] {
+ args.recipients[0].address =
+ Pubkey::find_program_address(&[seed, pool.as_ref()], &crate::ID).0;
+ assert!(validate_terms(&args, &pool).is_err());
+ }
+ args = terms();
+ args.recipients.push(args.recipients[0].clone());
+ assert!(validate_terms(&args, &pool).is_err());
+ args = terms();
+ args.recipients[0].weight_bps = 9999;
+ assert!(validate_terms(&args, &pool).is_err());
+ args = terms();
+ args.fee_bps = 0;
+ assert!(validate_terms(&args, &pool).is_err());
+ args.recipients.clear();
+ validate_terms(&args, &pool).unwrap();
+}
+#[test]
+fn off_curve_smart_wallet_recipient_is_supported() {
+ let mut args = terms();
+ args.recipients[0].address =
+ Pubkey::find_program_address(&[b"wallet"], &Pubkey::new_unique()).0;
+ assert!(!args.recipients[0].address.is_on_curve());
+ validate_terms(&args, &Pubkey::new_unique()).unwrap();
+}
+fn loader_accounts(authority: Option) -> (AccountInfo<'static>, AccountInfo<'static>) {
+ let data_key =
+ Pubkey::find_program_address(&[crate::ID.as_ref()], &bpf_loader_upgradeable::ID).0;
+ let program_bytes =
+ bincode::serialize(&bpf_loader_upgradeable::UpgradeableLoaderState::Program {
+ programdata_address: data_key,
+ })
+ .unwrap();
+ let mut data_bytes = bincode::serialize(
+ &bpf_loader_upgradeable::UpgradeableLoaderState::ProgramData {
+ slot: 1,
+ upgrade_authority_address: authority,
+ },
+ )
+ .unwrap();
+ data_bytes.resize(64, 0);
+ (
+ account(
+ crate::ID,
+ bpf_loader_upgradeable::ID,
+ program_bytes,
+ 1,
+ false,
+ false,
+ true,
+ ),
+ account(
+ data_key,
+ bpf_loader_upgradeable::ID,
+ data_bytes,
+ 1,
+ false,
+ false,
+ false,
+ ),
+ )
+}
+#[test]
+fn immutable_gate_requires_own_program_and_revoked_linked_data() {
+ let (executable, data) = loader_accounts(None);
+ verify_immutable(&executable, &data).unwrap();
+ let (_, mutable_data) = loader_accounts(Some(Pubkey::new_unique()));
+ assert!(verify_immutable(&executable, &mutable_data).is_err());
+ let unrelated = account(
+ Pubkey::new_unique(),
+ bpf_loader_upgradeable::ID,
+ data.try_borrow_data().unwrap().to_vec(),
+ 1,
+ false,
+ false,
+ false,
+ );
+ assert!(verify_immutable(&executable, &unrelated).is_err());
+ let wrong_owner = account(
+ *data.key,
+ system_program::ID,
+ data.try_borrow_data().unwrap().to_vec(),
+ 1,
+ false,
+ false,
+ false,
+ );
+ assert!(verify_immutable(&executable, &wrong_owner).is_err());
+ let non_executable = account(
+ crate::ID,
+ bpf_loader_upgradeable::ID,
+ executable.try_borrow_data().unwrap().to_vec(),
+ 1,
+ false,
+ false,
+ false,
+ );
+ assert!(verify_immutable(&non_executable, &data).is_err());
+ let spoofed_code =
+ bincode::serialize(&bpf_loader_upgradeable::UpgradeableLoaderState::Program {
+ programdata_address: Pubkey::new_unique(),
+ })
+ .unwrap();
+ let spoofed = account(
+ crate::ID,
+ bpf_loader_upgradeable::ID,
+ spoofed_code,
+ 1,
+ false,
+ false,
+ true,
+ );
+ assert!(verify_immutable(&spoofed, &data).is_err());
+ let short = account(
+ *data.key,
+ bpf_loader_upgradeable::ID,
+ vec![3, 0, 0, 0],
+ 1,
+ false,
+ false,
+ false,
+ );
+ assert!(verify_immutable(&executable, &short).is_err());
+}
+#[test]
+fn cancellation_runs_through_entrypoint_and_cannot_replay() {
+ let (key, pool) = fixture_pool(PREPARED);
+ let state = pool_account(key, &pool);
+ let creator = account(
+ pool.creator,
+ system_program::ID,
+ vec![],
+ 1,
+ true,
+ false,
+ false,
+ );
+ execute(
+ vec![creator.clone(), state.clone()],
+ crate::instruction::CancelPreparation {}.data(),
+ )
+ .unwrap();
+ let result = Pool::try_deserialize(&mut state.try_borrow_data().unwrap().as_ref()).unwrap();
+ assert_eq!(result.status, CANCELLED);
+ assert_eq!(state.lamports(), 100_000_000);
+ assert!(execute(
+ vec![creator, state],
+ crate::instruction::CancelPreparation {}.data()
+ )
+ .is_err());
+}
+#[test]
+fn active_cancellation_and_unsigned_wrong_creator_rejected() {
+ for (status, signer, wrong_creator) in [
+ (ACTIVE, true, false),
+ (PREPARED, false, false),
+ (PREPARED, true, true),
+ ] {
+ let (key, pool) = fixture_pool(status);
+ let creator = account(
+ if wrong_creator {
+ Pubkey::new_unique()
+ } else {
+ pool.creator
+ },
+ system_program::ID,
+ vec![],
+ 1,
+ signer,
+ false,
+ false,
+ );
+ assert!(execute(
+ vec![creator, pool_account(key, &pool)],
+ crate::instruction::CancelPreparation {}.data()
+ )
+ .is_err());
+ }
+}
+#[test]
+fn wrong_pool_owner_pda_and_readonly_state_rejected() {
+ for mode in 0..3 {
+ let (key, pool) = fixture_pool(PREPARED);
+ let creator = account(
+ pool.creator,
+ system_program::ID,
+ vec![],
+ 1,
+ true,
+ false,
+ false,
+ );
+ let state = account(
+ if mode == 0 { Pubkey::new_unique() } else { key },
+ if mode == 1 {
+ system_program::ID
+ } else {
+ crate::ID
+ },
+ serialized(&pool, Pool::SPACE),
+ 100_000_000,
+ false,
+ mode != 2,
+ false,
+ );
+ assert!(execute(
+ vec![creator, state],
+ crate::instruction::CancelPreparation {}.data()
+ )
+ .is_err());
+ }
+}
+fn claim_fixture(
+ donation: u64,
+) -> (
+ AccountInfo<'static>,
+ AccountInfo<'static>,
+ AccountInfo<'static>,
+) {
+ let (key, mut pool) = fixture_pool(ACTIVE);
+ pool.fees_earned = 777;
+ let fees_key = Pubkey::find_program_address(&[b"fees", key.as_ref()], &crate::ID).0;
+ let fee_state = SolVault {
+ pool: key,
+ kind: 1,
+ rent_floor: 1000,
+ };
+ (
+ pool_account(key, &pool),
+ account(
+ fees_key,
+ crate::ID,
+ serialized(&fee_state, SolVault::SPACE),
+ 1777 + donation,
+ false,
+ true,
+ false,
+ ),
+ account(
+ pool.creator,
+ system_program::ID,
+ vec![],
+ 500,
+ false,
+ true,
+ false,
+ ),
+ )
+}
+#[test]
+fn permissionless_claim_preserves_rent_and_donations_and_cannot_double_pay() {
+ let (pool, fees, recipient) = claim_fixture(5000);
+ execute(
+ vec![pool.clone(), fees.clone(), recipient.clone()],
+ crate::instruction::ClaimFees { recipient_index: 0 }.data(),
+ )
+ .unwrap();
+ assert_eq!(recipient.lamports(), 1277);
+ assert_eq!(fees.lamports(), 6000);
+ let state = Pool::try_deserialize(&mut pool.try_borrow_data().unwrap().as_ref()).unwrap();
+ assert_eq!(state.recipients[0].paid, 777);
+ assert_eq!(state.sol_reserve, 0);
+ assert_eq!(state.token_reserve, SUPPLY);
+ assert!(execute(
+ vec![pool, fees, recipient],
+ crate::instruction::ClaimFees { recipient_index: 0 }.data()
+ )
+ .is_err());
+}
+#[test]
+fn forged_recipient_and_fee_vault_substitution_rejected() {
+ let (pool, fees, recipient) = claim_fixture(0);
+ let attacker = account(
+ Pubkey::new_unique(),
+ system_program::ID,
+ vec![],
+ 1,
+ false,
+ true,
+ false,
+ );
+ assert!(execute(
+ vec![pool.clone(), fees.clone(), attacker],
+ crate::instruction::ClaimFees { recipient_index: 0 }.data()
+ )
+ .is_err());
+ let forged_fees = account(
+ Pubkey::new_unique(),
+ crate::ID,
+ fees.try_borrow_data().unwrap().to_vec(),
+ 1777,
+ false,
+ true,
+ false,
+ );
+ assert!(execute(
+ vec![pool.clone(), forged_fees, recipient.clone()],
+ crate::instruction::ClaimFees { recipient_index: 0 }.data()
+ )
+ .is_err());
+ assert!(execute(
+ vec![pool, fees, recipient],
+ crate::instruction::ClaimFees { recipient_index: 7 }.data()
+ )
+ .is_err());
+}
+#[test]
+fn claims_never_drain_protected_rent() {
+ let (pool, fees, recipient) = claim_fixture(0);
+ **fees.try_borrow_mut_lamports().unwrap() = 1776;
+ assert!(execute(
+ vec![pool, fees.clone(), recipient.clone()],
+ crate::instruction::ClaimFees { recipient_index: 0 }.data()
+ )
+ .is_err());
+ assert_eq!(fees.lamports(), 1776);
+ assert_eq!(recipient.lamports(), 500);
+}
+#[test]
+fn max_metadata_account_size_matches_abi() {
+ let (_, mut pool) = fixture_pool(PREPARED);
+ pool.name = "x".repeat(32);
+ pool.symbol = "x".repeat(10);
+ pool.uri = "x".repeat(200);
+ let mut bytes = Vec::new();
+ pool.try_serialize(&mut bytes).unwrap();
+ assert_eq!(bytes.len(), Pool::SPACE);
+}
+#[test]
+fn independent_golden_vectors() {
+ let rows: serde_json::Value = serde_json::from_str(include_str!(
+ "../../../../packages/solana-model/golden-vectors.json"
+ ))
+ .unwrap();
+ for row in rows.as_array().unwrap() {
+ let s = &row["state"];
+ let value = |v: &serde_json::Value| v.as_str().unwrap().parse::().unwrap();
+ let calc = if row["side"] == "buy" {
+ math::buy
+ } else {
+ math::sell
+ };
+ let q = calc(
+ value(&s["tokenInventory"]),
+ value(&s["realSolReserves"]),
+ value(&s["virtualSol"]),
+ s["feeBps"].as_u64().unwrap() as u16,
+ value(&row["input"]),
+ )
+ .unwrap();
+ let expected = &row["expected"];
+ assert_eq!(
+ q.amount_out,
+ value(&expected["amountOut"]),
+ "{}",
+ row["name"]
+ );
+ assert_eq!(q.fee, value(&expected["fee"]));
+ assert_eq!(q.token_reserve, value(&expected["nextTokenInventory"]));
+ assert_eq!(q.sol_reserve, value(&expected["nextRealSolReserves"]));
+ }
+}
+
+#[test]
+fn sdk_fixture_matches_rust_serialization_pdas_and_instruction_accounts() {
+ use anchor_lang::ToAccountMetas;
+ use std::str::FromStr;
+ let fixture: serde_json::Value = serde_json::from_str(include_str!(
+ "../../../../app/packages/solana-sdk/tests/abi-fixture.json"
+ ))
+ .unwrap();
+ let hex = |value: &serde_json::Value| {
+ let text = value.as_str().unwrap();
+ (0..text.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap())
+ .collect::>()
+ };
+ let pubkey = |value: &serde_json::Value| Pubkey::from_str(value.as_str().unwrap()).unwrap();
+ let bytes = hex(&fixture["preparedPoolHex"]);
+ let pool = Pool::try_deserialize(&mut bytes.as_slice()).unwrap();
+ assert_eq!(pool.creator, Pubkey::new_from_array([1; 32]));
+ assert_eq!(pool.nonce, 7);
+ assert_eq!(pool.status, PREPARED);
+ assert_eq!(pool.name, "Example");
+ assert_eq!(pool.symbol, "EX");
+ assert!(pool.uri.is_empty());
+ assert_eq!(pool.fee_bps, 0);
+ let mut round_trip = Vec::new();
+ pool.try_serialize(&mut round_trip).unwrap();
+ assert_eq!(round_trip, bytes);
+ let (pool_key, bump) = Pubkey::find_program_address(
+ &[b"pool", pool.creator.as_ref(), &7u64.to_le_bytes()],
+ &crate::ID,
+ );
+ assert_eq!(pool_key, pubkey(&fixture["addresses"]["pool"]));
+ assert_eq!(bump, pool.bump);
+ for (seed, name) in [
+ (b"mint".as_slice(), "mint"),
+ (b"token", "tokenVault"),
+ (b"reserve", "reserve"),
+ (b"fees", "fees"),
+ (b"mint-authority", "mintAuthority"),
+ ] {
+ assert_eq!(
+ Pubkey::find_program_address(&[seed, pool_key.as_ref()], &crate::ID).0,
+ pubkey(&fixture["addresses"][name])
+ );
+ }
+ let args = PrepareArgs {
+ nonce: 7,
+ virtual_sol: 30_000_000_000,
+ fee_bps: 0,
+ name: "Example".into(),
+ symbol: "EX".into(),
+ uri: String::new(),
+ content_hash: [0; 32],
+ recipients: vec![],
+ };
+ let instruction_data = [
+ (
+ "prepareLaunch",
+ crate::instruction::PrepareLaunch { args }.data(),
+ ),
+ (
+ "activateLaunch",
+ crate::instruction::ActivateLaunch {}.data(),
+ ),
+ (
+ "cancelPreparation",
+ crate::instruction::CancelPreparation {}.data(),
+ ),
+ (
+ "buyExactIn",
+ crate::instruction::BuyExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out: 10,
+ expiry_slot: 1000,
+ }
+ .data(),
+ ),
+ (
+ "sellExactIn",
+ crate::instruction::SellExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out: 10,
+ expiry_slot: 1000,
+ }
+ .data(),
+ ),
+ (
+ "claimFees",
+ crate::instruction::ClaimFees { recipient_index: 0 }.data(),
+ ),
+ ];
+ for (name, bytes) in instruction_data {
+ assert_eq!(
+ bytes,
+ hex(&fixture["instructions"][name]["dataHex"]),
+ "{name}"
+ );
+ }
+ let address = |name: &str| pubkey(&fixture["addresses"][name]);
+ let trade = crate::accounts::Trade {
+ trader: pool.creator,
+ pool: pool_key,
+ mint: address("mint"),
+ token_vault: address("tokenVault"),
+ reserve: address("reserve"),
+ fees: address("fees"),
+ trader_token: pubkey(&fixture["instructions"]["buyExactIn"]["keys"][6]["address"]),
+ token_program: token::ID,
+ system_program: system_program::ID,
+ };
+ let metas = [
+ (
+ "prepareLaunch",
+ crate::accounts::PrepareLaunch {
+ creator: pool.creator,
+ payer: pool.creator,
+ pool: pool_key,
+ system_program: system_program::ID,
+ }
+ .to_account_metas(None),
+ ),
+ (
+ "activateLaunch",
+ crate::accounts::ActivateLaunch {
+ creator: pool.creator,
+ pool: pool_key,
+ mint: address("mint"),
+ token_vault: address("tokenVault"),
+ reserve: address("reserve"),
+ fees: address("fees"),
+ mint_authority: address("mintAuthority"),
+ executable: crate::ID,
+ program_data: Pubkey::find_program_address(
+ &[crate::ID.as_ref()],
+ &bpf_loader_upgradeable::ID,
+ )
+ .0,
+ token_program: token::ID,
+ system_program: system_program::ID,
+ rent: anchor_lang::solana_program::sysvar::rent::ID,
+ }
+ .to_account_metas(None),
+ ),
+ (
+ "cancelPreparation",
+ crate::accounts::CancelPreparation {
+ creator: pool.creator,
+ pool: pool_key,
+ }
+ .to_account_metas(None),
+ ),
+ ("buyExactIn", trade.to_account_metas(None)),
+ ("sellExactIn", trade.to_account_metas(None)),
+ (
+ "claimFees",
+ crate::accounts::ClaimFees {
+ pool: pool_key,
+ fees: address("fees"),
+ recipient: pool.creator,
+ }
+ .to_account_metas(None),
+ ),
+ ];
+ for (name, keys) in metas {
+ let expected = fixture["instructions"][name]["keys"].as_array().unwrap();
+ assert_eq!(keys.len(), expected.len());
+ for (key, expected) in keys.iter().zip(expected) {
+ assert_eq!(key.pubkey, pubkey(&expected["address"]), "{name}");
+ assert_eq!(
+ key.is_signer,
+ expected["signer"].as_bool().unwrap(),
+ "{name}"
+ );
+ assert_eq!(
+ key.is_writable,
+ expected["writable"].as_bool().unwrap(),
+ "{name}"
+ );
+ }
+ }
+}
diff --git a/solana/runtime-tests/Cargo.toml b/solana/runtime-tests/Cargo.toml
new file mode 100644
index 00000000..2edf4215
--- /dev/null
+++ b/solana/runtime-tests/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "launch-pool-svm"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "MIT"
+
+[features]
+svm-tests = []
+
+[[test]]
+name = "lifecycle"
+path = "tests/lifecycle.rs"
+required-features = ["svm-tests"]
+
+[dev-dependencies]
+anchor-lang = "=0.31.1"
+anchor-spl = { version = "=0.31.1", default-features = false, features = ["token"] }
+bincode = "=1.3.3"
+# Anchor's legacy dependency shares this permitted five8_core version with
+# modern host SDKs. The host-only Error impl must be enabled for keypair parsing.
+five8_core = { version = "=0.1.2", features = ["std"] }
+launch-pool = { path = "../programs/launch_pool" }
+litesvm = "=0.13.1"
+solana-account = "=3.4.0"
+solana-address = "=2.4.0"
+solana-clock = "=3.0.1"
+solana-instruction = "=3.2.0"
+solana-keypair = "=3.1.2"
+solana-message = "=3.1.0"
+solana-sdk-ids = "=3.1.0"
+solana-signer = "=3.0.0"
+solana-transaction = "=3.1.0"
diff --git a/solana/runtime-tests/tests/lifecycle.rs b/solana/runtime-tests/tests/lifecycle.rs
new file mode 100644
index 00000000..f667ed14
--- /dev/null
+++ b/solana/runtime-tests/tests/lifecycle.rs
@@ -0,0 +1,561 @@
+//! Offline execution of the compiled SBPFv3 ELF with real System/SPL programs.
+//! Ephemeral VM-only identities are generated in memory; no RPC or wallets.
+use anchor_lang::prelude::Pubkey;
+#[allow(deprecated)]
+// Exercise the same legacy System instruction bytes as Anchor's CPI client.
+use anchor_lang::solana_program::{
+ instruction::Instruction as OldInstruction, program_pack::Pack, system_instruction,
+};
+use anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas};
+use anchor_spl::token::spl_token::{
+ self,
+ state::{Account as TokenAccount, Mint},
+};
+use launch_pool::{
+ math::SUPPLY, Pool, PrepareArgs, RecipientInput, SolVault, ACTIVE, CANCELLED, PREPARED,
+};
+use litesvm::{
+ types::{FailedTransactionMetadata, TransactionMetadata},
+ LiteSVM,
+};
+use solana_address::Address;
+use solana_clock::Clock;
+use solana_instruction::{account_meta::AccountMeta, Instruction};
+use solana_keypair::Keypair;
+use solana_message::Message;
+use solana_signer::Signer;
+use solana_transaction::Transaction;
+
+fn address(key: Pubkey) -> Address {
+ Address::new_from_array(key.to_bytes())
+}
+fn pubkey(key: Address) -> Pubkey {
+ Pubkey::new_from_array(key.to_bytes())
+}
+fn convert(ix: OldInstruction) -> Instruction {
+ Instruction {
+ program_id: address(ix.program_id),
+ accounts: ix
+ .accounts
+ .into_iter()
+ .map(|a| AccountMeta {
+ pubkey: address(a.pubkey),
+ is_signer: a.is_signer,
+ is_writable: a.is_writable,
+ })
+ .collect(),
+ data: ix.data,
+ }
+}
+fn instruction(accounts: impl ToAccountMetas, data: impl InstructionData) -> Instruction {
+ convert(OldInstruction {
+ program_id: launch_pool::ID,
+ accounts: accounts.to_account_metas(None),
+ data: data.data(),
+ })
+}
+
+struct Fixture {
+ last_packet_bytes: usize,
+ svm: LiteSVM,
+ payer: Keypair,
+ nonce: u64,
+ pool: Pubkey,
+ mint: Pubkey,
+ token: Pubkey,
+ reserve: Pubkey,
+ fees: Pubkey,
+ mint_authority: Pubkey,
+ program_data: Pubkey,
+}
+impl Fixture {
+ fn new() -> Self {
+ let mut svm = LiteSVM::new();
+ let bytes = std::fs::read(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../target/deploy/launch_pool.so"
+ ))
+ .expect("Build ELF with scripts/check-sbf.sh before svm-tests");
+ svm.add_program(address(launch_pool::ID), &bytes).unwrap();
+ let payer = Keypair::new();
+ svm.airdrop(&payer.pubkey(), 100_000_000_000).unwrap();
+ let nonce = 7u64;
+ let creator = pubkey(payer.pubkey());
+ let pool = Pubkey::find_program_address(
+ &[b"pool", creator.as_ref(), &nonce.to_le_bytes()],
+ &launch_pool::ID,
+ )
+ .0;
+ let derive =
+ |seed: &[u8]| Pubkey::find_program_address(&[seed, pool.as_ref()], &launch_pool::ID).0;
+ let program_data = Pubkey::find_program_address(
+ &[launch_pool::ID.as_ref()],
+ &anchor_lang::solana_program::bpf_loader_upgradeable::ID,
+ )
+ .0;
+ Self {
+ last_packet_bytes: 0,
+ svm,
+ payer,
+ nonce,
+ pool,
+ mint: derive(b"mint"),
+ token: derive(b"token"),
+ reserve: derive(b"reserve"),
+ fees: derive(b"fees"),
+ mint_authority: derive(b"mint-authority"),
+ program_data,
+ }
+ }
+ fn send(
+ &mut self,
+ instructions: &[Instruction],
+ extra: &[&Keypair],
+ ) -> Result> {
+ self.svm.expire_blockhash();
+ let blockhash = self.svm.latest_blockhash();
+ let mut signers = vec![&self.payer];
+ signers.extend_from_slice(extra);
+ let transaction = Transaction::new(
+ &signers,
+ Message::new_with_blockhash(instructions, Some(&self.payer.pubkey()), &blockhash),
+ blockhash,
+ );
+ let bytes = bincode::serialize(&transaction).unwrap();
+ self.last_packet_bytes = bytes.len();
+ assert!(bytes.len() <= 1232, "packet {} exceeds 1232", bytes.len());
+ self.svm.send_transaction(transaction).map_err(Box::new)
+ }
+ fn args(&self) -> PrepareArgs {
+ PrepareArgs {
+ nonce: self.nonce,
+ virtual_sol: 30_000_000_000,
+ fee_bps: 100,
+ name: "Openlaunch test".into(),
+ symbol: "TEST".into(),
+ uri: String::new(),
+ content_hash: [0; 32],
+ recipients: vec![RecipientInput {
+ address: pubkey(self.payer.pubkey()),
+ weight_bps: 10_000,
+ }],
+ }
+ }
+ fn prepare_ix(&self, args: PrepareArgs) -> Instruction {
+ instruction(
+ launch_pool::accounts::PrepareLaunch {
+ creator: pubkey(self.payer.pubkey()),
+ payer: pubkey(self.payer.pubkey()),
+ pool: self.pool,
+ system_program: anchor_lang::system_program::ID,
+ },
+ launch_pool::instruction::PrepareLaunch { args },
+ )
+ }
+ fn prepare(&mut self) {
+ let ix = self.prepare_ix(self.args());
+ let result = self.send(&[ix], &[]).unwrap();
+ println!("prepare CU={}", result.compute_units_consumed);
+ }
+ fn activate_ix(&self) -> Instruction {
+ instruction(
+ launch_pool::accounts::ActivateLaunch {
+ creator: pubkey(self.payer.pubkey()),
+ pool: self.pool,
+ mint: self.mint,
+ token_vault: self.token,
+ reserve: self.reserve,
+ fees: self.fees,
+ mint_authority: self.mint_authority,
+ executable: launch_pool::ID,
+ program_data: self.program_data,
+ token_program: spl_token::ID,
+ system_program: anchor_lang::system_program::ID,
+ rent: anchor_lang::solana_program::sysvar::rent::ID,
+ },
+ launch_pool::instruction::ActivateLaunch {},
+ )
+ }
+ fn activate(&mut self) {
+ let ix = self.activate_ix();
+ let result = self.send(&[ix], &[]).unwrap();
+ println!("activate CU={}", result.compute_units_consumed);
+ }
+ fn pool_state(&self) -> Pool {
+ Pool::try_deserialize(
+ &mut self
+ .svm
+ .get_account(&address(self.pool))
+ .unwrap()
+ .data
+ .as_slice(),
+ )
+ .unwrap()
+ }
+ fn vault_state(&self, key: Pubkey) -> SolVault {
+ SolVault::try_deserialize(&mut self.svm.get_account(&address(key)).unwrap().data.as_slice())
+ .unwrap()
+ }
+ fn lamports(&self, key: Pubkey) -> u64 {
+ self.svm.get_account(&address(key)).unwrap().lamports
+ }
+ fn token_amount(&self, key: Pubkey) -> u64 {
+ TokenAccount::unpack(&self.svm.get_account(&address(key)).unwrap().data)
+ .unwrap()
+ .amount
+ }
+ fn create_trader_token(&mut self) -> Pubkey {
+ let token = Keypair::new();
+ let token_key = pubkey(token.pubkey());
+ let create = convert(system_instruction::create_account(
+ &pubkey(self.payer.pubkey()),
+ &token_key,
+ self.svm
+ .minimum_balance_for_rent_exemption(TokenAccount::LEN),
+ TokenAccount::LEN as u64,
+ &spl_token::ID,
+ ));
+ let initialize = convert(
+ spl_token::instruction::initialize_account3(
+ &spl_token::ID,
+ &token_key,
+ &self.mint,
+ &pubkey(self.payer.pubkey()),
+ )
+ .unwrap(),
+ );
+ self.send(&[create, initialize], &[&token]).unwrap();
+ token_key
+ }
+ fn trade_accounts(&self, trader_token: Pubkey) -> launch_pool::accounts::Trade {
+ launch_pool::accounts::Trade {
+ trader: pubkey(self.payer.pubkey()),
+ pool: self.pool,
+ mint: self.mint,
+ token_vault: self.token,
+ reserve: self.reserve,
+ fees: self.fees,
+ trader_token,
+ token_program: spl_token::ID,
+ system_program: anchor_lang::system_program::ID,
+ }
+ }
+ fn buy_ix(&self, trader_token: Pubkey, input: u64, minimum: u64) -> Instruction {
+ instruction(
+ self.trade_accounts(trader_token),
+ launch_pool::instruction::BuyExactIn {
+ amount_in: input,
+ minimum_out: minimum,
+ expiry_slot: self.svm.get_sysvar::().slot + 100,
+ },
+ )
+ }
+ fn sell_ix(&self, trader_token: Pubkey, input: u64, minimum: u64) -> Instruction {
+ instruction(
+ self.trade_accounts(trader_token),
+ launch_pool::instruction::SellExactIn {
+ amount_in: input,
+ minimum_out: minimum,
+ expiry_slot: self.svm.get_sysvar::().slot + 100,
+ },
+ )
+ }
+ fn claim_ix(&self, index: u8, recipient: Pubkey) -> Instruction {
+ instruction(
+ launch_pool::accounts::ClaimFees {
+ pool: self.pool,
+ fees: self.fees,
+ recipient,
+ },
+ launch_pool::instruction::ClaimFees {
+ recipient_index: index,
+ },
+ )
+ }
+ fn cancel_ix(&self) -> Instruction {
+ instruction(
+ launch_pool::accounts::CancelPreparation {
+ creator: pubkey(self.payer.pubkey()),
+ pool: self.pool,
+ },
+ launch_pool::instruction::CancelPreparation {},
+ )
+ }
+ fn prefund(&mut self, key: Pubkey) {
+ let ix = convert(system_instruction::transfer(
+ &pubkey(self.payer.pubkey()),
+ &key,
+ 1_000_000,
+ ));
+ self.send(&[ix], &[]).unwrap();
+ }
+}
+
+#[test]
+fn real_sbf_lifecycle_atomic_mint_revoke_buy_sell_claim() {
+ let mut f = Fixture::new();
+ f.prepare();
+ assert_eq!(f.pool_state().status, PREPARED);
+ f.activate();
+ let state = f.pool_state();
+ assert_eq!(state.status, ACTIVE);
+ assert_eq!(state.token_reserve, SUPPLY);
+ let mint = Mint::unpack(&f.svm.get_account(&address(f.mint)).unwrap().data).unwrap();
+ assert_eq!(mint.supply, SUPPLY);
+ assert_eq!(mint.decimals, 6);
+ assert!(mint.mint_authority.is_none());
+ assert!(mint.freeze_authority.is_none());
+ assert_eq!(f.token_amount(f.token), SUPPLY);
+ let token = f.create_trader_token();
+ let buy = f.buy_ix(token, 1_000_000_000, 31_945_788_964_181);
+ let result = f.send(&[buy], &[]).unwrap();
+ println!("buy CU={}", result.compute_units_consumed);
+ assert_eq!(f.token_amount(token), 31_945_788_964_181);
+ assert_eq!(f.pool_state().sol_reserve, 990_000_000);
+ let sell = f.sell_ix(token, f.token_amount(token), 980_099_999);
+ let result = f.send(&[sell], &[]).unwrap();
+ println!("sell CU={}", result.compute_units_consumed);
+ assert_eq!(f.token_amount(token), 0);
+ assert_eq!(f.pool_state().sol_reserve, 1);
+ assert_eq!(f.pool_state().fees_earned, 19_900_000);
+ let rent = f.vault_state(f.fees).rent_floor;
+ let claim = f.claim_ix(0, pubkey(f.payer.pubkey()));
+ let result = f.send(std::slice::from_ref(&claim), &[]).unwrap();
+ println!("claim CU={}", result.compute_units_consumed);
+ assert_eq!(f.lamports(f.fees), rent);
+ assert_eq!(f.pool_state().recipients[0].paid, 19_900_000);
+ assert!(f.send(&[claim], &[]).is_err());
+ assert!(f.send(&[f.cancel_ix()], &[]).is_err());
+ assert!(f.send(&[f.activate_ix()], &[]).is_err());
+}
+
+#[test]
+fn prefunded_pool_and_all_activation_pdas_cannot_grief_creation() {
+ for index in 0..5 {
+ let mut f = Fixture::new();
+ let target = [f.pool, f.mint, f.token, f.reserve, f.fees][index];
+ f.prefund(target);
+ f.prepare();
+ f.activate();
+ assert_eq!(f.pool_state().status, ACTIVE);
+ assert_eq!(f.token_amount(f.token), SUPPLY);
+ }
+}
+
+#[test]
+fn cancelled_nonce_is_terminal_and_active_pool_cannot_reinitialize() {
+ let mut f = Fixture::new();
+ f.prepare();
+ let cancel = f.cancel_ix();
+ f.send(std::slice::from_ref(&cancel), &[]).unwrap();
+ assert_eq!(f.pool_state().status, CANCELLED);
+ assert!(f.send(&[cancel], &[]).is_err());
+ assert!(f.send(&[f.prepare_ix(f.args())], &[]).is_err());
+ assert!(f.send(&[f.activate_ix()], &[]).is_err());
+ assert!(f.svm.get_account(&address(f.mint)).is_none());
+}
+
+#[test]
+fn failed_later_instruction_rolls_back_successful_activation_and_buy_cpis() {
+ let mut f = Fixture::new();
+ f.prepare();
+ // Activation succeeds internally; the following active cancellation must fail.
+ assert!(f.send(&[f.activate_ix(), f.cancel_ix()], &[]).is_err());
+ assert_eq!(f.pool_state().status, PREPARED);
+ assert!(f.svm.get_account(&address(f.mint)).is_none());
+ f.activate();
+ let token = f.create_trader_token();
+ let initial_fees = f.lamports(f.fees);
+ let initial_reserve = f.lamports(f.reserve);
+ let good_buy = f.buy_ix(token, 1_000_000_000, 1);
+ let impossible_buy = f.buy_ix(token, 1_000_000_000, SUPPLY);
+ assert!(f.send(&[good_buy, impossible_buy], &[]).is_err());
+ assert_eq!(f.token_amount(token), 0);
+ assert_eq!(f.pool_state().sequence, 0);
+ assert_eq!(f.lamports(f.fees), initial_fees);
+ assert_eq!(f.lamports(f.reserve), initial_reserve);
+}
+
+#[test]
+fn maximum_terms_fit_packet_and_compute_limits() {
+ let mut f = Fixture::new();
+ let mut args = f.args();
+ args.name = "N".repeat(32);
+ args.symbol = "S".repeat(10);
+ args.uri = "u".repeat(200);
+ args.recipients = (0..7)
+ .map(|i| RecipientInput {
+ address: Pubkey::new_unique(),
+ weight_bps: if i == 6 { 1432 } else { 1428 },
+ })
+ .collect();
+ let ix = f.prepare_ix(args);
+ let meta = f.send(&[ix], &[]).unwrap();
+ println!(
+ "max prepare CU={} packet={} bytes",
+ meta.compute_units_consumed, f.last_packet_bytes
+ );
+ f.activate();
+ assert_eq!(f.pool_state().recipient_count, 7);
+}
+
+#[test]
+fn wrong_custody_accounts_and_expiry_reject_without_movement() {
+ let mut f = Fixture::new();
+ f.prepare();
+ f.activate();
+ let token = f.create_trader_token();
+ for mode in 0..5 {
+ let mut ix = f.buy_ix(token, 1_000_000_000, 1);
+ match mode {
+ 0 => ix.accounts.swap(4, 5),
+ 1 => ix.accounts[6].pubkey = address(f.token),
+ 2 => ix.accounts[7].pubkey = solana_sdk_ids::system_program::ID,
+ 3 => ix.accounts[2].pubkey = address(f.token),
+ 4 => {
+ f.svm.warp_to_slot(200);
+ ix.data = launch_pool::instruction::BuyExactIn {
+ amount_in: 1_000_000_000,
+ minimum_out: 1,
+ expiry_slot: 100,
+ }
+ .data();
+ }
+ _ => unreachable!(),
+ }
+ assert!(f.send(&[ix], &[]).is_err());
+ assert_eq!(f.pool_state().sequence, 0);
+ assert_eq!(f.token_amount(token), 0);
+ }
+}
+
+#[test]
+fn mutable_or_unrelated_programdata_cannot_activate() {
+ #[allow(deprecated)]
+ use anchor_lang::solana_program::bpf_loader_upgradeable::UpgradeableLoaderState;
+ let mut f = Fixture::new();
+ f.prepare();
+ let mut account = f.svm.get_account(&address(f.program_data)).unwrap();
+ let state = bincode::serialize(&UpgradeableLoaderState::ProgramData {
+ slot: 0,
+ upgrade_authority_address: Some(pubkey(f.payer.pubkey())),
+ })
+ .unwrap();
+ account.data[..45].copy_from_slice(&state);
+ f.svm.set_account(address(f.program_data), account).unwrap();
+ assert!(f.send(&[f.activate_ix()], &[]).is_err());
+ assert_eq!(f.pool_state().status, PREPARED);
+ assert!(f.svm.get_account(&address(f.mint)).is_none());
+ let mut wrong_ix = f.activate_ix();
+ wrong_ix.accounts[8].pubkey = f.payer.pubkey();
+ assert!(f.send(&[wrong_ix], &[]).is_err());
+}
+
+#[test]
+fn donations_and_holder_burns_do_not_change_quotes_or_fee_claims() {
+ let mut f = Fixture::new();
+ f.prepare();
+ f.activate();
+ let token = f.create_trader_token();
+ f.send(&[f.buy_ix(token, 1_000_000_000, 1)], &[]).unwrap();
+ let payer = pubkey(f.payer.pubkey());
+ let donate_token = convert(
+ spl_token::instruction::transfer_checked(
+ &spl_token::ID,
+ &token,
+ &f.mint,
+ &f.token,
+ &payer,
+ &[],
+ 1_000_000,
+ 6,
+ )
+ .unwrap(),
+ );
+ let burn = convert(
+ spl_token::instruction::burn_checked(
+ &spl_token::ID,
+ &token,
+ &f.mint,
+ &payer,
+ &[],
+ 2_000_000,
+ 6,
+ )
+ .unwrap(),
+ );
+ let donate_sol = convert(system_instruction::transfer(
+ &payer,
+ &f.reserve,
+ 5_000_000_000,
+ ));
+ let donate_fee = convert(system_instruction::transfer(&payer, &f.fees, 1_000_000));
+ f.send(&[donate_token, burn, donate_sol, donate_fee], &[])
+ .unwrap();
+ let before = f.pool_state();
+ let q = launch_pool::math::buy(
+ before.token_reserve,
+ before.sol_reserve,
+ before.virtual_sol,
+ before.fee_bps,
+ 1_000_000_000,
+ )
+ .unwrap();
+ f.send(&[f.buy_ix(token, 1_000_000_000, q.amount_out)], &[])
+ .unwrap();
+ assert_eq!(f.pool_state().sol_reserve, q.sol_reserve);
+ assert_eq!(f.token_amount(f.token), q.token_reserve + 1_000_000);
+ let quote_rent = f.vault_state(f.reserve).rent_floor;
+ assert_eq!(
+ f.lamports(f.reserve),
+ quote_rent + q.sol_reserve + 5_000_000_000
+ );
+ f.send(&[f.claim_ix(0, payer)], &[]).unwrap();
+ assert_eq!(
+ f.lamports(f.fees),
+ f.vault_state(f.fees).rent_floor + 1_000_000
+ );
+ let sell = f.sell_ix(token, f.token_amount(token), 1);
+ f.send(&[sell], &[]).unwrap();
+ assert!(f.lamports(f.reserve) >= quote_rent + 5_000_000_000);
+ let extra_mint = convert(
+ spl_token::instruction::mint_to_checked(&spl_token::ID, &f.mint, &token, &payer, &[], 1, 6)
+ .unwrap(),
+ );
+ assert!(f.send(&[extra_mint], &[]).is_err());
+ let freeze = convert(
+ spl_token::instruction::freeze_account(&spl_token::ID, &token, &f.mint, &payer, &[])
+ .unwrap(),
+ );
+ assert!(f.send(&[freeze], &[]).is_err());
+}
+
+#[test]
+fn rent_incompatible_recipient_cannot_block_another_recipient_claim() {
+ let mut f = Fixture::new();
+ let unused = Pubkey::new_unique();
+ let payer = pubkey(f.payer.pubkey());
+ let mut args = f.args();
+ args.recipients = vec![
+ RecipientInput {
+ address: unused,
+ weight_bps: 5000,
+ },
+ RecipientInput {
+ address: payer,
+ weight_bps: 5000,
+ },
+ ];
+ f.send(&[f.prepare_ix(args)], &[]).unwrap();
+ f.activate();
+ let token = f.create_trader_token();
+ f.send(&[f.buy_ix(token, 100, 1)], &[]).unwrap();
+ f.send(&[f.buy_ix(token, 100, 1)], &[]).unwrap();
+ assert_eq!(f.pool_state().fees_earned, 2);
+ // One lamport cannot initialize a rent-exempt destination account.
+ assert!(f.send(&[f.claim_ix(0, unused)], &[]).is_err());
+ assert_eq!(f.pool_state().recipients[0].paid, 0);
+ f.send(&[f.claim_ix(1, payer)], &[]).unwrap();
+ assert_eq!(f.pool_state().recipients[1].paid, 1);
+ assert_eq!(f.pool_state().recipients[0].paid, 0);
+}
diff --git a/solana/scripts/check-sbf.sh b/solana/scripts/check-sbf.sh
new file mode 100644
index 00000000..2c522966
--- /dev/null
+++ b/solana/scripts/check-sbf.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Build only. No RPC, wallet, deployment, key upload or authority mutation.
+set -euo pipefail
+cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
+
+build_sbf="${SBF_BUILD_BIN:-.tools/bin/cargo-build-sbf}"
+if [[ ! -x "$build_sbf" ]]; then
+ printf '%s\n' 'Install locally first: cargo install cargo-build-sbf --version 4.2.0 --locked --root .tools' >&2
+ exit 1
+fi
+tool_version="$($build_sbf --version)"
+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; }
+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
+ printf '%s\n' 'SBF diagnostics require review; do not release this binary.' >&2
+ exit 1
+fi
+test -s target/deploy/launch_pool.so
+printf '%s\n' 'SBF compilation passed. This is not validator testing, audit approval, or deployment authorization.'
From ea24bdc3d8f815fd7bc8a5eca60fb8afdd24b377 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 15:36:02 +0530
Subject: [PATCH 02/11] feat(solana): add verified transaction SDK
Keep quotes exact, bind instructions to canonical accounts, and verify signed messages and deployment bytes. Preserve transaction identities through uncertain submissions.
---
app/packages/solana-sdk/README.md | 89 +++++++
app/packages/solana-sdk/package.json | 14 +
.../solana-sdk/scripts/print-abi-fixture.ts | 29 +++
.../solana-sdk/scripts/verify-manifest.ts | 46 ++++
app/packages/solana-sdk/src/accounts.ts | 124 +++++++++
app/packages/solana-sdk/src/encoding.ts | 51 ++++
app/packages/solana-sdk/src/index.ts | 5 +
app/packages/solana-sdk/src/instructions.ts | 54 ++++
app/packages/solana-sdk/src/math.ts | 108 ++++++++
app/packages/solana-sdk/src/transactions.ts | 71 +++++
app/packages/solana-sdk/src/verification.ts | 31 +++
.../solana-sdk/tests/abi-fixture.json | 243 ++++++++++++++++++
.../solana-sdk/tests/accounts.test.ts | 58 +++++
.../solana-sdk/tests/instructions.test.ts | 51 ++++
app/packages/solana-sdk/tests/math.test.ts | 68 +++++
.../solana-sdk/tests/transactions.test.ts | 48 ++++
.../solana-sdk/tests/verification.test.ts | 27 ++
app/packages/solana-sdk/tsconfig.json | 8 +
18 files changed, 1125 insertions(+)
create mode 100644 app/packages/solana-sdk/README.md
create mode 100644 app/packages/solana-sdk/package.json
create mode 100644 app/packages/solana-sdk/scripts/print-abi-fixture.ts
create mode 100644 app/packages/solana-sdk/scripts/verify-manifest.ts
create mode 100644 app/packages/solana-sdk/src/accounts.ts
create mode 100644 app/packages/solana-sdk/src/encoding.ts
create mode 100644 app/packages/solana-sdk/src/index.ts
create mode 100644 app/packages/solana-sdk/src/instructions.ts
create mode 100644 app/packages/solana-sdk/src/math.ts
create mode 100644 app/packages/solana-sdk/src/transactions.ts
create mode 100644 app/packages/solana-sdk/src/verification.ts
create mode 100644 app/packages/solana-sdk/tests/abi-fixture.json
create mode 100644 app/packages/solana-sdk/tests/accounts.test.ts
create mode 100644 app/packages/solana-sdk/tests/instructions.test.ts
create mode 100644 app/packages/solana-sdk/tests/math.test.ts
create mode 100644 app/packages/solana-sdk/tests/transactions.test.ts
create mode 100644 app/packages/solana-sdk/tests/verification.test.ts
create mode 100644 app/packages/solana-sdk/tsconfig.json
diff --git a/app/packages/solana-sdk/README.md b/app/packages/solana-sdk/README.md
new file mode 100644
index 00000000..17f6a108
--- /dev/null
+++ b/app/packages/solana-sdk/README.md
@@ -0,0 +1,89 @@
+# Openlaunch Solana SDK
+
+Original TypeScript client for the unreleased immutable launch-pool program. This package is not an audited protocol and includes no production program ID. The compile/test fixture ID must never be used as a deployment configuration.
+
+Kept inside `app/packages` so the existing app-only Docker build context contains the local package. Import `@openlaunch/solana-sdk` from the app. Source exports require a TypeScript-aware bundler or `tsx`; Next supports this without a separate generated client. Public keys remain case-sensitive base58. Amounts are `bigint` in token atoms or SOL lamports, never JavaScript floating-point amounts.
+
+## Dependencies
+
+- `@solana/web3.js` 1.99.0: pinned class-based transaction/account API shared with the app wallet integration. The v1 line is maintenance-only; a future major upgrade requires rerunning wallet message and serialization tests, not an unreviewed range bump.
+- `@noble/hashes` 1.8.0: browser-compatible SHA-256 for Anchor discriminators and reviewed ELF verification. No private-key handling.
+- `buffer` 6.0.3: explicit browser-compatible binary encoding instead of relying on a global Node `Buffer`.
+
+The SDK does not depend on Anchor's JavaScript client, a DEX SDK, SPL Token metadata services, server signing, or an external quote service.
+
+## Public API
+
+| API | Result / contract |
+| --- | --- |
+| `derivePoolAddresses(programId, creator, nonce)` | Canonical pool, mint, token vault, SOL reserve, fee vault and temporary mint-authority PDAs; `nonce` is u64 bigint. |
+| `deriveAssociatedTokenAddress(mint, owner)` | Legacy SPL associated token account, including supported off-curve owners. |
+| `decodePoolAccount(address, accountInfo, programId)` | Checked `PoolAccount`; requires owner, discriminator, ABI version, canonical PDAs and immutable terms. |
+| `fetchPoolSnapshot(connection, programId, address)` | `{ address, addresses, pool, slot }`; account and custody checks use one confirmed-bank batch. Accepts donated surplus; refuses unsafe numeric RPC balances. |
+| `verifyImmutableProgram(connection, programId)` | Finalized linked Program/ProgramData check; rejects retained upgrade authority or wrong loader. |
+| `quoteBuy(state, grossLamports, minOutput = 1n)` | Exact SOL-in buy, fee rounded up and token output rounded down. |
+| `quoteSell(state, tokenAtoms, minOutput = 1n)` | Exact token-in sell; rejects any attempt to spend virtual SOL. |
+| `feeEntitlement(earned, weightBps)` / `claimableFees(earned, weightBps, paid)` | Checked lifetime u128 accounting without multiplication overflow. |
+| `minimumAfterSlippage(output, bps)` | Conservative minimum; refuses zero or 100% slippage. |
+| `parseAmount(text, decimals)` / `formatAmount(value, decimals)` | Exact human amount handling; rejects exponents, signs and excessive fractional digits. |
+
+`PoolAccount` contains `status: "prepared" | "active" | "cancelled"`, `creator`, `rentPayer`, `mint`, `nonce`, `virtualSol`, `feeBps`, `recipients`, `tokenInventory`, `realSolReserves`, `feesEarned`, `sequence`, `name`, `symbol`, `uri`, and `contentHash`. Every recipient contains `{ address: PublicKey, weightBps: number, paid: bigint }`. Empty URI is valid; metadata lives in our program, not an automatically recognized wallet metadata mirror.
+
+`CurveState` needs `{ tokenInventory, realSolReserves, virtualSol, feeBps }`. Both quote functions return `{ grossInput, netInput, fee, amountOut, nextTokenInventory, nextRealSolReserves }`. On sells, `grossInput` and `netInput` are token atoms; `fee` and `amountOut` are lamports. Virtual offset is not reserve liquidity or collateral. The technical 1 through 1,000,000 SOL offset bounds are not endorsed valuations and remain subject to economic review.
+
+### Instruction builders
+
+All builders return a `TransactionInstruction`, never send or sign it.
+
+- `buildPrepareLaunchInstruction(programId, { creator, payer?, nonce, virtualSol, feeBps, name, symbol, uri, contentHash, recipients })` binds immutable configuration. `contentHash` is exactly 32 bytes. `recipients` is an array of `{ address, weightBps }`, empty for 0% fees.
+- `buildActivateLaunchInstruction(programId, creator, nonce)` activates only after the program's own upgrade authority is absent. The creator pays activation rent.
+- `buildCancelPreparationInstruction(programId, creator, nonce)` makes preparation terminal. The retained tombstone is not a refundable account.
+- `buildBuyInstruction(programId, tradeArgs)` / `buildSellInstruction(programId, tradeArgs)` use `{ creator, nonce, trader, traderToken?, amountIn, minimumOut, expirySlot }`. Omitting `traderToken` selects the canonical ATA; it still needs to exist.
+- `buildCreateAssociatedTokenInstruction(payer, mint, owner)` may be prepended to a buy, using the official idempotent ATA instruction. Rent is extra, not deducted from the quote.
+- `buildClaimFeesInstruction(programId, pool, recipientIndex)` pays the immutable recipient only. Any wallet may pay transaction fees to trigger a claim; it cannot redirect the payment.
+
+See [`solana/ABI.md`](../../../solana/ABI.md) for exact account order and instruction layouts. Name/symbol/URI sizes count UTF-8 bytes. Slippage and expiry are enforced again on-chain; a client quote is not an execution guarantee.
+
+### Signing and ambiguous-submission recovery
+
+```ts
+const prepared = await prepareTransaction(connection, walletPublicKey, instructions);
+await simulatePreparedTransaction(connection, prepared);
+
+// Request wallet-standard signing of prepared.wireBytes, using the current account/cluster.
+const signed = validateSignedTransaction(prepared.messageBytes, walletSignedBytes, walletPublicKey);
+// Persist signed.signature, prepared.blockhash and prepared.lastValidBlockHeight BEFORE submission.
+const submitted = await submitSignedTransaction(connection, signed);
+const result = await reconcileTransaction(connection, signed.signature, prepared.lastValidBlockHeight);
+```
+
+The app must check the selected wallet account/cluster again after the wallet prompt. Returned transaction messages must be byte-identical to the reviewed message and all required signatures valid. The SDK intentionally supports legacy transactions only in this first integration. SDK preparation/simulation contains no user signer.
+
+Submission errors return `unknown`, retaining the locally derived signature. Do not create a replacement transaction after a timeout. Reconciliation reads finalized block height and historical signature status. A processed/confirmed failure remains `pending` because it could exist only on a fork. Only finalized failure or expiry with no observed signature is terminal. Reliable archival RPC history is part of the operational assumptions; compare another trusted endpoint for unresolved anomalies. Never silently clear recovery state just because the UI was closed.
+
+## Read-only deployment evidence
+
+From `app/`, set `SOLANA_VERIFY_RPC_URL` privately, then run:
+
+```text
+npx tsx packages/solana-sdk/scripts/verify-manifest.ts
+```
+
+The manifest requires `schema: 1`, `cluster` (`devnet` or `mainnet-beta`), `programId`, canonical `programData`, an independently verified `genesisHash`, exact 40-character `sourceCommit`, 64-character `binarySha256`, nullable `verifiedSlot`, nullable `securityReview` / `economicReview`, and `releaseApproved` boolean.
+
+The checker compares RPC genesis, reads both this program and the legacy Token Program at one finalized context, checks their actual linked ProgramData and absent upgrade authorities, hashes the reviewed local ELF, and compares every deployed byte with it (only zero allocation padding is tolerated). It prints proof observations to stdout and **never** changes the manifest, grants approval, deploys, sends transactions or changes authority. Error output excludes RPC URLs, provider errors, local file paths and environment values.
+
+Retain its output with a reproducible source-to-ELF build attestation and a second independently trusted RPC check. Matching an ELF supplied by the caller does not prove that ELF came from the reviewed source, that the RPC is honest, that the program is secure, or that an audit is complete. `releaseApproved` in evidence is always false. Human release approval remains a separate reviewed action.
+
+## Verification
+
+From `app/`:
+
+```text
+npx tsc --noEmit -p packages/solana-sdk/tsconfig.json
+npx tsx --test packages/solana-sdk/tests/*.test.ts
+```
+
+Tests cover golden values shared with Rust, independent binary-search oracle comparisons, u64/u128 limits, fee rounding, slippage, decoder/PDA substitution, donated surplus, immutable ProgramData linkage, wallet message mutation, partial/invalid signatures, ambiguous submission and packet measurements. `tests/abi-fixture.json` contains only deterministic public fixture identities and serialized ABI bytes, no private key or deployable configuration. `scripts/print-abi-fixture.ts` reproduces it for intentional ABI changes.
+
+Measured unsigned legacy packet sizes with one signer: maximum-length seven-recipient preparation 792 bytes, activation 509 bytes, buy with idempotent ATA creation 508 bytes. Preparation and activation are separate transactions. These checks do not measure runtime compute, rent, wallet compatibility or real-validator lifecycle behavior; those remain separate release gates.
diff --git a/app/packages/solana-sdk/package.json b/app/packages/solana-sdk/package.json
new file mode 100644
index 00000000..1911abd1
--- /dev/null
+++ b/app/packages/solana-sdk/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@openlaunch/solana-sdk",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": { ".": "./src/index.ts", "./math": "./src/math.ts" },
+ "scripts": {
+ "test": "tsx --test tests/*.test.ts",
+ "typecheck": "tsc --noEmit",
+ "verify-manifest": "tsx scripts/verify-manifest.ts"
+ },
+ "dependencies": { "@solana/web3.js": "1.99.0", "@noble/hashes": "1.8.0", "buffer": "6.0.3" },
+ "devDependencies": { "@types/node": "^22", "tsx": "4.23.13", "typescript": "5.9.3" }
+}
diff --git a/app/packages/solana-sdk/scripts/print-abi-fixture.ts b/app/packages/solana-sdk/scripts/print-abi-fixture.ts
new file mode 100644
index 00000000..4881520e
--- /dev/null
+++ b/app/packages/solana-sdk/scripts/print-abi-fixture.ts
@@ -0,0 +1,29 @@
+/** Emits public deterministic fixture JSON; never creates keys or transactions on a network. */
+import { Buffer } from "buffer";
+import { PublicKey } from "@solana/web3.js";
+import { derivePoolAddresses } from "../src/accounts.ts";
+import { discriminator, textField, u8, u16, u64 } from "../src/encoding.ts";
+import { buildActivateLaunchInstruction, buildBuyInstruction, buildCancelPreparationInstruction, buildClaimFeesInstruction, buildPrepareLaunchInstruction, buildSellInstruction } from "../src/instructions.ts";
+
+const programId = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
+const creator = new PublicKey(new Uint8Array(32).fill(1));
+const nonce = 7n;
+const a = derivePoolAddresses(programId, creator, nonce);
+const prepare = { creator, nonce, virtualSol: 30_000_000_000n, feeBps: 0, name: "Example", symbol: "EX", uri: "", contentHash: new Uint8Array(32), recipients: [] };
+const trade = { creator, nonce, trader: creator, amountIn: 1_000_000_000n, minimumOut: 10n, expirySlot: 1000n };
+const pool = Buffer.concat([discriminator("account", "Pool"), u8(1), u8(0), u8(a.bump), u64(nonce), creator.toBuffer(), creator.toBuffer(), a.mint.toBuffer(), u64(prepare.virtualSol), u16(0), u8(0), Buffer.alloc(7 * 50), u64(0n), u64(0n), Buffer.alloc(16), u64(0n), textField("Example", 32, "name"), textField("EX", 10, "symbol"), textField("", 200, "uri", true), Buffer.alloc(32)]);
+const instructions = {
+ prepareLaunch: buildPrepareLaunchInstruction(programId, prepare),
+ activateLaunch: buildActivateLaunchInstruction(programId, creator, nonce),
+ cancelPreparation: buildCancelPreparationInstruction(programId, creator, nonce),
+ buyExactIn: buildBuyInstruction(programId, trade),
+ sellExactIn: buildSellInstruction(programId, trade),
+ claimFees: buildClaimFeesInstruction(programId, { creator, nonce, recipients: [{ address: creator, weightBps: 10000, paid: 0n }] }, 0),
+};
+console.log(JSON.stringify({
+ note: "Undeployed fixture identity only; no private key or network activity.",
+ programId: programId.toBase58(), creator: creator.toBase58(), nonce: nonce.toString(),
+ addresses: Object.fromEntries(Object.entries(a).map(([key, value]) => [key, typeof value === "number" ? value : value.toBase58()])),
+ preparedPoolHex: pool.toString("hex"),
+ instructions: Object.fromEntries(Object.entries(instructions).map(([key, ix]) => [key, { dataHex: ix.data.toString("hex"), keys: ix.keys.map((k) => ({ address: k.pubkey.toBase58(), signer: k.isSigner, writable: k.isWritable })) }])),
+}, null, 2));
diff --git a/app/packages/solana-sdk/scripts/verify-manifest.ts b/app/packages/solana-sdk/scripts/verify-manifest.ts
new file mode 100644
index 00000000..3a032eb3
--- /dev/null
+++ b/app/packages/solana-sdk/scripts/verify-manifest.ts
@@ -0,0 +1,46 @@
+/** Read-only release evidence. No wallet, private-key input, transaction, deployment or authority operation. */
+import { readFile } from "node:fs/promises";
+import { Connection, PublicKey } from "@solana/web3.js";
+import { decodeImmutableProgram, programDataAddress, TOKEN_PROGRAM_ID } from "../src/accounts.ts";
+import { validateDeploymentManifest, verifyDeploymentBytes } from "../src/verification.ts";
+
+let stage = "read manifest and reviewed ELF";
+try {
+ const [manifestPath, elfPath, ...extra] = process.argv.slice(2);
+ if (!manifestPath || !elfPath || extra.length > 0) throw new Error("usage");
+ const manifest = validateDeploymentManifest(JSON.parse(await readFile(manifestPath, "utf8")));
+ const elf = await readFile(elfPath);
+ stage = "connect to the explicitly configured read-only RPC";
+ const rpcUrl = process.env.SOLANA_VERIFY_RPC_URL;
+ if (!rpcUrl) throw new Error("Missing RPC configuration");
+ const url = new URL(rpcUrl);
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("HTTPS required");
+ const connection = new Connection(rpcUrl, { commitment: "finalized", disableRetryOnRateLimit: true, fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(20_000) }) });
+ stage = "match the RPC genesis hash to the reviewed manifest";
+ if (await connection.getGenesisHash() !== manifest.genesisHash) throw new Error("Wrong cluster");
+ stage = "read finalized program and dependency accounts";
+ const programId = new PublicKey(manifest.programId);
+ const ownData = new PublicKey(manifest.programData);
+ const tokenData = programDataAddress(TOKEN_PROGRAM_ID);
+ const { context, value } = await connection.getMultipleAccountsInfoAndContext([programId, ownData, TOKEN_PROGRAM_ID, tokenData], { commitment: "finalized" });
+ const [program, programData, tokenProgram, tokenProgramData] = value;
+ if (!program || !programData || !tokenProgram || !tokenProgramData) throw new Error("Missing program accounts");
+ stage = "verify linked immutable launch and legacy Token programs";
+ const own = decodeImmutableProgram(programId, program, ownData, programData);
+ const token = decodeImmutableProgram(TOKEN_PROGRAM_ID, tokenProgram, tokenData, tokenProgramData);
+ stage = "compare deployed program bytes with the reviewed local ELF";
+ const hash = verifyDeploymentBytes(programData.data, elf, manifest.binarySha256);
+ console.log(JSON.stringify({
+ schema: 1, evidence: "read-only-rpc-and-binary-check", observedAt: new Date().toISOString(),
+ cluster: manifest.cluster, genesisHash: manifest.genesisHash, programId: manifest.programId,
+ programData: manifest.programData, sourceCommit: manifest.sourceCommit, binarySha256: hash,
+ verifiedSlot: context.slot, deploymentSlot: own.deploymentSlot.toString(), upgradeAuthority: null,
+ tokenProgram: TOKEN_PROGRAM_ID.toBase58(), tokenProgramData: tokenData.toBase58(), tokenDeploymentSlot: token.deploymentSlot.toString(), tokenUpgradeAuthority: null,
+ releaseApproved: false,
+ limitation: "This evidence is not an audit, source-to-binary reproducibility proof, or release approval. Independently verify using another trusted RPC and retain the build attestation.",
+ }, null, 2));
+} catch {
+ // RPC URLs can contain API secrets. Never print third-party errors, stacks, input paths or environment values.
+ console.error(`Verification failed at: ${stage}. No network state was changed. Check configuration and rerun. Usage: tsx packages/solana-sdk/scripts/verify-manifest.ts ; set SOLANA_VERIFY_RPC_URL privately.`);
+ process.exitCode = 1;
+}
diff --git a/app/packages/solana-sdk/src/accounts.ts b/app/packages/solana-sdk/src/accounts.ts
new file mode 100644
index 00000000..9aec21fc
--- /dev/null
+++ b/app/packages/solana-sdk/src/accounts.ts
@@ -0,0 +1,124 @@
+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";
+
+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;
+export type PoolAddresses = {
+ pool: PublicKey; mint: PublicKey; tokenVault: PublicKey; reserve: PublicKey;
+ fees: PublicKey; mintAuthority: PublicKey; bump: number;
+};
+export function derivePoolAddresses(programId: PublicKey, creator: PublicKey, nonce: bigint): PoolAddresses {
+ const [pool, bump] = PublicKey.findProgramAddressSync([utf8.encode("pool"), creator.toBytes(), u64(nonce)], programId);
+ 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") };
+}
+export function deriveAssociatedTokenAddress(mint: PublicKey, owner: PublicKey): PublicKey {
+ return PublicKey.findProgramAddressSync([owner.toBytes(), TOKEN_PROGRAM_ID.toBytes(), mint.toBytes()], ASSOCIATED_TOKEN_PROGRAM_ID)[0];
+}
+export type Recipient = { address: PublicKey; weightBps: number; paid: bigint };
+export type PoolAccount = CurveState & {
+ version: 1; status: "prepared" | "active" | "cancelled"; bump: number; nonce: bigint;
+ creator: PublicKey; rentPayer: PublicKey; mint: PublicKey; recipients: Recipient[];
+ feesEarned: bigint; sequence: bigint; name: string; symbol: string; uri: string; contentHash: Uint8Array;
+};
+
+/** Owner and canonical PDA validation are required, not optional decoder flags. */
+export function decodePoolAccount(address: PublicKey, info: AccountInfo, programId: PublicKey): PoolAccount {
+ if (!info.owner.equals(programId) || info.executable) throw new Error("Wrong pool owner");
+ if (info.data.length > POOL_ACCOUNT_SPACE) throw new Error("Unsupported pool allocation size");
+ const r = new Reader(info.data);
+ if (!r.take(8).equals(discriminator("account", "Pool"))) throw new Error("Wrong pool discriminator");
+ const version = r.byte(); const statusByte = r.byte(); const bump = r.byte();
+ if (version !== 1 || statusByte > 2) throw new Error("Unsupported pool version or status");
+ const nonce = r.long(); const creator = r.key(); const rentPayer = r.key(); const mint = r.key();
+ const virtualSol = r.long(); const feeBps = r.short(); const recipientCount = r.byte();
+ if (recipientCount > 7 || !FEE_BPS.some((fee) => fee === feeBps) || virtualSol < MIN_VIRTUAL_SOL || virtualSol > MAX_VIRTUAL_SOL) throw new Error("Invalid pool terms");
+ const recipients: Recipient[] = [];
+ for (let i = 0; i < 7; i++) {
+ const recipient = { address: r.key(), weightBps: r.short(), paid: r.wide() };
+ if (i < recipientCount) recipients.push(recipient);
+ else if (!recipient.address.equals(PublicKey.default) || recipient.weightBps !== 0 || recipient.paid !== 0n) throw new Error("Nonempty unused recipient");
+ }
+ const tokenInventory = r.long(); const realSolReserves = r.long(); const feesEarned = r.wide(); const sequence = r.long();
+ const name = r.text(32); const symbol = r.text(10); const uri = r.text(200, true); const contentHash = new Uint8Array(r.take(32)); r.finish();
+ const addresses = derivePoolAddresses(programId, creator, nonce);
+ if (!address.equals(addresses.pool) || bump !== addresses.bump || !mint.equals(addresses.mint)) throw new Error("Noncanonical pool or mint");
+ validateRecipients(recipients, feeBps, addresses, programId);
+ for (const recipient of recipients) claimableFees(feesEarned, recipient.weightBps, recipient.paid);
+ if (recipients.reduce((sum, x) => sum + x.paid, 0n) > feesEarned) throw new Error("Fee liabilities are invalid");
+ const result: PoolAccount = {
+ version, status: (["prepared", "active", "cancelled"] as const)[statusByte], bump, nonce, creator, rentPayer, mint,
+ virtualSol, feeBps, recipients, tokenInventory, realSolReserves, feesEarned, sequence, name, symbol, uri, contentHash,
+ };
+ if (result.status === "active") validateCurve(result);
+ else if (tokenInventory !== 0n || realSolReserves !== 0n || feesEarned !== 0n || sequence !== 0n) throw new Error("Inactive pool has live accounting");
+ return result;
+}
+export function validateRecipients(recipients: ReadonlyArray<{ address: PublicKey; weightBps: number }>, feeBps: number, addresses?: PoolAddresses, programId?: PublicKey): void {
+ if (!FEE_BPS.some((fee) => fee === feeBps)) throw new Error("Unsupported fee");
+ if (feeBps === 0 ? recipients.length !== 0 : recipients.length < 1 || recipients.length > 7) throw new Error("Invalid recipient count");
+ const seen = new Set();
+ const forbidden = addresses ? [addresses.pool, addresses.mint, addresses.tokenVault, addresses.reserve, addresses.fees, addresses.mintAuthority] : [];
+ for (const r of recipients) {
+ if (r.address.equals(PublicKey.default) || (programId && r.address.equals(programId)) || forbidden.some((x) => x.equals(r.address)) || seen.has(r.address.toBase58()) || !Number.isInteger(r.weightBps) || r.weightBps < 1 || r.weightBps > 10_000) throw new Error("Invalid or duplicate recipient");
+ seen.add(r.address.toBase58());
+ }
+ if (feeBps !== 0 && recipients.reduce((sum, r) => sum + r.weightBps, 0) !== 10_000) throw new Error("Recipient weights must total 10000");
+}
+export function programDataAddress(programId: PublicKey): PublicKey {
+ return PublicKey.findProgramAddressSync([programId.toBytes()], UPGRADEABLE_LOADER_ID)[0];
+}
+export function decodeImmutableProgram(programId: PublicKey, executable: AccountInfo, dataAddress: PublicKey, data: AccountInfo): { deploymentSlot: bigint } {
+ if (!executable.executable || !executable.owner.equals(UPGRADEABLE_LOADER_ID) || executable.data.length !== 36 || executable.data.readUInt32LE(0) !== 2) throw new Error("Unsupported executable loader state");
+ const recorded = new PublicKey(executable.data.subarray(4, 36));
+ if (!recorded.equals(dataAddress) || !dataAddress.equals(programDataAddress(programId))) throw new Error("ProgramData does not belong to this program");
+ if (data.executable || !data.owner.equals(UPGRADEABLE_LOADER_ID) || data.data.length < 45 || data.data.readUInt32LE(0) !== 3) throw new Error("Invalid ProgramData");
+ if (data.data[12] !== 0) throw new Error("Program still has an upgrade authority");
+ return { deploymentSlot: data.data.readBigUInt64LE(4) };
+}
+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" });
+ if (!value[0] || !value[1]) throw new Error("Program is not deployed");
+ return { programData, ...decodeImmutableProgram(programId, value[0], programData, value[1]), slot: context.slot };
+}
+
+function actualLamports(info: AccountInfo): bigint {
+ // web3.js v1 exposes RPC lamports as number: refuse unsafe rounding instead of inventing precision.
+ if (!Number.isSafeInteger(info.lamports) || info.lamports < 0) throw new Error("RPC lamport balance exceeds safe integer precision");
+ return BigInt(info.lamports);
+}
+function verifySolVault(info: AccountInfo, programId: PublicKey, pool: PublicKey, kind: number, liability: bigint): void {
+ if (!info.owner.equals(programId) || info.executable || info.data.length !== SOL_VAULT_SPACE) throw new Error("Invalid SOL vault owner or size");
+ const r = new Reader(info.data);
+ if (!r.take(8).equals(discriminator("account", "SolVault")) || !r.key().equals(pool) || r.byte() !== kind) throw new Error("Invalid SOL vault linkage");
+ const rentFloor = r.long();
+ if (actualLamports(info) < rentFloor + liability) throw new Error("SOL vault is underfunded");
+}
+export type PoolSnapshot = { address: PublicKey; addresses: PoolAddresses; pool: PoolAccount; slot: number };
+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);
+ // 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");
+ if (value[0].data.length !== POOL_ACCOUNT_SPACE) throw new Error("Unexpected on-chain pool allocation");
+ const pool = decodePoolAccount(address, value[0], programId);
+ if (pool.status === "active") {
+ const [, mint, token, reserve, fees] = value;
+ if (!mint || !token || !reserve || !fees) throw new Error("Missing active custody accounts");
+ if (!mint.owner.equals(TOKEN_PROGRAM_ID) || mint.executable || mint.data.length !== 82 || mint.data.readUInt32LE(0) !== 0 || mint.data[44] !== TOKEN_DECIMALS || mint.data[45] !== 1 || mint.data.readUInt32LE(46) !== 0 || mint.data.readBigUInt64LE(36) > FIXED_SUPPLY) throw new Error("Invalid mint authorities or supply");
+ if (!token.owner.equals(TOKEN_PROGRAM_ID) || token.executable || token.data.length !== 165 || !new PublicKey(token.data.subarray(0, 32)).equals(addresses.mint) || !new PublicKey(token.data.subarray(32, 64)).equals(address) || token.data[108] !== 1 || token.data.readUInt32LE(72) !== 0 || token.data.readUInt32LE(109) !== 0 || token.data.readUInt32LE(129) !== 0 || token.data.readBigUInt64LE(64) < pool.tokenInventory) throw new Error("Invalid token vault custody");
+ 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 };
+}
diff --git a/app/packages/solana-sdk/src/encoding.ts b/app/packages/solana-sdk/src/encoding.ts
new file mode 100644
index 00000000..9405b739
--- /dev/null
+++ b/app/packages/solana-sdk/src/encoding.ts
@@ -0,0 +1,51 @@
+import { Buffer } from "buffer";
+import { sha256 } from "@noble/hashes/sha256";
+import { PublicKey } from "@solana/web3.js";
+import { bounded, U64_MAX } from "./math.ts";
+
+export const utf8 = new TextEncoder();
+export const discriminator = (namespace: "global" | "account" | "event", name: string): Buffer =>
+ Buffer.from(sha256(utf8.encode(`${namespace}:${name}`)).slice(0, 8));
+export function u8(n: number): Buffer {
+ if (!Number.isInteger(n) || n < 0 || n > 255) throw new RangeError("Invalid u8");
+ return Buffer.from([n]);
+}
+export function u16(n: number): Buffer {
+ if (!Number.isInteger(n) || n < 0 || n > 65535) throw new RangeError("Invalid u16");
+ const b = Buffer.alloc(2); b.writeUInt16LE(n); return b;
+}
+export function u32(n: number): Buffer {
+ if (!Number.isSafeInteger(n) || n < 0 || n > 0xffffffff) throw new RangeError("Invalid u32");
+ const b = Buffer.alloc(4); b.writeUInt32LE(n); return b;
+}
+export function u64(n: bigint): Buffer {
+ bounded(n, U64_MAX, "u64");
+ const b = Buffer.alloc(8); b.writeBigUInt64LE(n); return b;
+}
+export function textField(text: string, maxBytes: number, label: string, allowEmpty = false): Buffer {
+ const bytes = Buffer.from(utf8.encode(text));
+ if ((!allowEmpty && text.trim().length === 0) || bytes.length > maxBytes || /\p{Cc}/u.test(text)) throw new RangeError(`Invalid ${label}`);
+ return Buffer.concat([u32(bytes.length), bytes]);
+}
+export class Reader {
+ offset = 0;
+ constructor(readonly data: Buffer) {}
+ take(length: number): Buffer {
+ if (!Number.isSafeInteger(length) || length < 0 || this.offset + length > this.data.length) throw new Error("Truncated account data");
+ const result = this.data.subarray(this.offset, this.offset + length); this.offset += length; return result;
+ }
+ byte(): number { return this.take(1)[0]; }
+ short(): number { return this.take(2).readUInt16LE(0); }
+ int(): number { return this.take(4).readUInt32LE(0); }
+ long(): bigint { return this.take(8).readBigUInt64LE(0); }
+ wide(): bigint { return this.long() + (this.long() << 64n); }
+ key(): PublicKey { return new PublicKey(this.take(32)); }
+ text(max: number, allowEmpty = false): string {
+ const length = this.int();
+ if ((!allowEmpty && length === 0) || length > max) throw new Error("Invalid account text length");
+ const value = new TextDecoder("utf-8", { fatal: true }).decode(this.take(length));
+ if ((!allowEmpty && value.trim().length === 0) || /\p{Cc}/u.test(value)) throw new Error("Invalid account text");
+ return value;
+ }
+ finish(): void { if (this.take(this.data.length - this.offset).some((x) => x !== 0)) throw new Error("Unexpected account data suffix"); }
+}
diff --git a/app/packages/solana-sdk/src/index.ts b/app/packages/solana-sdk/src/index.ts
new file mode 100644
index 00000000..cb2d3c4b
--- /dev/null
+++ b/app/packages/solana-sdk/src/index.ts
@@ -0,0 +1,5 @@
+export * from "./math.ts";
+export * from "./accounts.ts";
+export * from "./instructions.ts";
+export * from "./transactions.ts";
+export * from "./verification.ts";
diff --git a/app/packages/solana-sdk/src/instructions.ts b/app/packages/solana-sdk/src/instructions.ts
new file mode 100644
index 00000000..9b065e8a
--- /dev/null
+++ b/app/packages/solana-sdk/src/instructions.ts
@@ -0,0 +1,54 @@
+import { Buffer } from "buffer";
+import { PublicKey, SystemProgram, SYSVAR_RENT_PUBKEY, TransactionInstruction, type AccountMeta } from "@solana/web3.js";
+import { ASSOCIATED_TOKEN_PROGRAM_ID, deriveAssociatedTokenAddress, derivePoolAddresses, programDataAddress, TOKEN_PROGRAM_ID, validateRecipients, type PoolAccount } from "./accounts.ts";
+import { discriminator, textField, u8, u16, u32, u64 } from "./encoding.ts";
+import { bounded, MAX_VIRTUAL_SOL, MIN_VIRTUAL_SOL, U64_MAX } from "./math.ts";
+
+const meta = (pubkey: PublicKey, isWritable = false, isSigner = false): AccountMeta => ({ pubkey, isWritable, isSigner });
+const instruction = (programId: PublicKey, name: string, keys: AccountMeta[], args: Buffer[] = []) =>
+ new TransactionInstruction({ programId, keys, data: Buffer.concat([discriminator("global", name), ...args]) });
+export type PrepareLaunchArgs = {
+ creator: PublicKey; payer?: PublicKey; nonce: bigint; virtualSol: bigint; feeBps: number;
+ name: string; symbol: string; uri: string; contentHash: Uint8Array;
+ recipients: ReadonlyArray<{ address: PublicKey; weightBps: number }>;
+};
+export function buildPrepareLaunchInstruction(programId: PublicKey, args: PrepareLaunchArgs): TransactionInstruction {
+ const addresses = derivePoolAddresses(programId, args.creator, args.nonce);
+ validateRecipients(args.recipients, args.feeBps, addresses, programId);
+ bounded(args.virtualSol, MAX_VIRTUAL_SOL, "Virtual SOL");
+ if (args.virtualSol < MIN_VIRTUAL_SOL || args.contentHash.length !== 32) throw new Error("Invalid valuation or content hash");
+ return instruction(programId, "prepare_launch", [meta(args.creator, false, true), meta(args.payer ?? args.creator, true, true), meta(addresses.pool, true), meta(SystemProgram.programId)], [
+ u64(args.nonce), u64(args.virtualSol), u16(args.feeBps), textField(args.name, 32, "name"), textField(args.symbol, 10, "symbol"), textField(args.uri, 200, "URI", true), Buffer.from(args.contentHash),
+ u32(args.recipients.length), ...args.recipients.flatMap((r) => [r.address.toBuffer(), u16(r.weightBps)]),
+ ]);
+}
+export function buildActivateLaunchInstruction(programId: PublicKey, creator: PublicKey, nonce: bigint): TransactionInstruction {
+ const a = derivePoolAddresses(programId, creator, nonce);
+ return instruction(programId, "activate_launch", [meta(creator, true, true), meta(a.pool, true), meta(a.mint, true), meta(a.tokenVault, true), meta(a.reserve, true), meta(a.fees, true), meta(a.mintAuthority), meta(programId), meta(programDataAddress(programId)), meta(TOKEN_PROGRAM_ID), meta(SystemProgram.programId), meta(SYSVAR_RENT_PUBKEY)]);
+}
+export function buildCancelPreparationInstruction(programId: PublicKey, creator: PublicKey, nonce: bigint): TransactionInstruction {
+ return instruction(programId, "cancel_preparation", [meta(creator, false, true), meta(derivePoolAddresses(programId, creator, nonce).pool, true)]);
+}
+export type TradeInstructionArgs = {
+ creator: PublicKey; nonce: bigint; trader: PublicKey; traderToken?: PublicKey;
+ amountIn: bigint; minimumOut: bigint; expirySlot: bigint;
+};
+function tradeInstruction(programId: PublicKey, name: string, args: TradeInstructionArgs): TransactionInstruction {
+ const a = derivePoolAddresses(programId, args.creator, args.nonce);
+ bounded(args.amountIn, U64_MAX, "Amount in"); bounded(args.minimumOut, U64_MAX, "Minimum out");
+ if (args.amountIn === 0n || args.minimumOut === 0n) throw new Error("Input and slippage minimum must be positive");
+ const traderToken = args.traderToken ?? deriveAssociatedTokenAddress(a.mint, args.trader);
+ return instruction(programId, name, [meta(args.trader, true, true), meta(a.pool, true), meta(a.mint), meta(a.tokenVault, true), meta(a.reserve, true), meta(a.fees, true), meta(traderToken, true), meta(TOKEN_PROGRAM_ID), meta(SystemProgram.programId)], [u64(args.amountIn), u64(args.minimumOut), u64(args.expirySlot)]);
+}
+export const buildBuyInstruction = (programId: PublicKey, args: TradeInstructionArgs): TransactionInstruction => tradeInstruction(programId, "buy_exact_in", args);
+export const buildSellInstruction = (programId: PublicKey, args: TradeInstructionArgs): TransactionInstruction => tradeInstruction(programId, "sell_exact_in", args);
+export function buildClaimFeesInstruction(programId: PublicKey, pool: Pick, recipientIndex: number): TransactionInstruction {
+ if (!Number.isInteger(recipientIndex) || recipientIndex < 0 || recipientIndex >= pool.recipients.length) throw new Error("Invalid recipient index");
+ const a = derivePoolAddresses(programId, pool.creator, pool.nonce);
+ return instruction(programId, "claim_fees", [meta(a.pool, true), meta(a.fees, true), meta(pool.recipients[recipientIndex].address, true)], [u8(recipientIndex)]);
+}
+/** Idempotent official ATA creation. Rent/fees are extra; this never wraps SOL. */
+export function buildCreateAssociatedTokenInstruction(payer: PublicKey, mint: PublicKey, owner: PublicKey): TransactionInstruction {
+ return new TransactionInstruction({ programId: ASSOCIATED_TOKEN_PROGRAM_ID,
+ keys: [meta(payer, true, true), meta(deriveAssociatedTokenAddress(mint, owner), true), meta(owner), meta(mint), meta(SystemProgram.programId), meta(TOKEN_PROGRAM_ID)], data: Buffer.from([1]) });
+}
diff --git a/app/packages/solana-sdk/src/math.ts b/app/packages/solana-sdk/src/math.ts
new file mode 100644
index 00000000..20d2c34f
--- /dev/null
+++ b/app/packages/solana-sdk/src/math.ts
@@ -0,0 +1,108 @@
+/** Exact atom/lamport arithmetic. Virtual SOL is never available liquidity. */
+export const FIXED_SUPPLY = 1_000_000_000_000_000n;
+export const TOKEN_DECIMALS = 6;
+export const BPS = 10_000n;
+export const U64_MAX = (1n << 64n) - 1n;
+export const U128_MAX = (1n << 128n) - 1n;
+export const MIN_VIRTUAL_SOL = 1_000_000_000n;
+export const MAX_VIRTUAL_SOL = 1_000_000_000_000_000n;
+export const FEE_BPS = [0, 100, 300] as const;
+
+export type CurveState = {
+ tokenInventory: bigint;
+ realSolReserves: bigint;
+ virtualSol: bigint;
+ feeBps: number;
+};
+export type TradeQuote = {
+ grossInput: bigint;
+ netInput: bigint;
+ fee: bigint;
+ amountOut: bigint;
+ nextTokenInventory: bigint;
+ nextRealSolReserves: bigint;
+};
+
+export function bounded(value: bigint, max: bigint, name: string): bigint {
+ if (typeof value !== "bigint" || value < 0n || value > max) throw new RangeError(`${name} is out of bounds`);
+ return value;
+}
+export function validateCurve(state: CurveState): void {
+ bounded(state.tokenInventory, FIXED_SUPPLY, "Token inventory");
+ bounded(state.realSolReserves, U64_MAX, "Real SOL reserves");
+ bounded(state.virtualSol, MAX_VIRTUAL_SOL, "Virtual SOL");
+ if (state.tokenInventory === 0n || state.virtualSol < MIN_VIRTUAL_SOL) throw new RangeError("Invalid inventory or virtual SOL");
+ if (!FEE_BPS.some((fee) => fee === state.feeBps)) throw new RangeError("Fee must be 0, 100, or 300 basis points");
+ bounded(state.realSolReserves + state.virtualSol, U64_MAX, "Effective reserves");
+ if (state.tokenInventory * (state.realSolReserves + state.virtualSol) < FIXED_SUPPLY * state.virtualSol) {
+ throw new RangeError("Pool invariant violated");
+ }
+}
+export function tradingFee(amount: bigint, feeBps: number): bigint {
+ bounded(amount, U64_MAX, "Fee basis amount");
+ if (!FEE_BPS.some((fee) => fee === feeBps)) throw new RangeError("Unsupported fee");
+ const product = amount * BigInt(feeBps);
+ return product / BPS + (product % BPS === 0n ? 0n : 1n);
+}
+export function quoteBuy(state: CurveState, grossLamports: bigint, minOutput = 1n): TradeQuote {
+ validateCurve(state);
+ bounded(grossLamports, U64_MAX, "Buy input");
+ bounded(minOutput, U64_MAX, "Minimum output");
+ const fee = tradingFee(grossLamports, state.feeBps);
+ const netInput = grossLamports - fee;
+ if (netInput <= 0n) throw new RangeError("Buy input is too small after fees");
+ const denominator = bounded(state.realSolReserves + state.virtualSol + netInput, U64_MAX, "Effective reserves after buy");
+ const amountOut = state.tokenInventory * netInput / denominator;
+ if (amountOut <= 0n || amountOut >= state.tokenInventory || amountOut < minOutput) throw new RangeError("Buy output is zero or below minimum");
+ return { grossInput: grossLamports, netInput, fee, amountOut,
+ nextTokenInventory: state.tokenInventory - amountOut, nextRealSolReserves: state.realSolReserves + netInput };
+}
+export function quoteSell(state: CurveState, tokenAtoms: bigint, minOutput = 1n): TradeQuote {
+ validateCurve(state);
+ bounded(tokenAtoms, U64_MAX, "Sell input");
+ bounded(minOutput, U64_MAX, "Minimum output");
+ if (tokenAtoms === 0n || tokenAtoms > FIXED_SUPPLY - state.tokenInventory) throw new RangeError("Sell exceeds circulating inventory");
+ const grossOutput = (state.realSolReserves + state.virtualSol) * tokenAtoms / (state.tokenInventory + tokenAtoms);
+ if (grossOutput > state.realSolReserves) throw new RangeError("Sell would spend virtual SOL");
+ const fee = tradingFee(grossOutput, state.feeBps);
+ const amountOut = grossOutput - fee;
+ if (amountOut <= 0n || amountOut < minOutput) throw new RangeError("Sell output is zero or below minimum");
+ return { grossInput: tokenAtoms, netInput: tokenAtoms, fee, amountOut,
+ nextTokenInventory: state.tokenInventory + tokenAtoms, nextRealSolReserves: state.realSolReserves - grossOutput };
+}
+/** Same checked-u128 quotient/remainder formula as the program, avoiding F * weight overflow. */
+export function feeEntitlement(cumulativeFees: bigint, weightBps: number): bigint {
+ bounded(cumulativeFees, U128_MAX, "Cumulative fees");
+ if (!Number.isInteger(weightBps) || weightBps < 1 || weightBps > 10_000) throw new RangeError("Invalid beneficiary weight");
+ const weight = BigInt(weightBps);
+ return cumulativeFees / BPS * weight + cumulativeFees % BPS * weight / BPS;
+}
+export function claimableFees(cumulativeFees: bigint, weightBps: number, paid: bigint): bigint {
+ bounded(paid, U128_MAX, "Paid fees");
+ const entitlement = feeEntitlement(cumulativeFees, weightBps);
+ if (paid > entitlement) throw new RangeError("Paid fees exceed entitlement");
+ return entitlement - paid;
+}
+export function minimumAfterSlippage(amountOut: bigint, slippageBps: number): bigint {
+ bounded(amountOut, U64_MAX, "Quoted output");
+ if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps >= 10_000) throw new RangeError("Slippage must be below 100%");
+ const result = amountOut * (BPS - BigInt(slippageBps)) / BPS;
+ if (result <= 0n) throw new RangeError("Slippage minimum would be zero");
+ return result;
+}
+/** Parse without float rounding or scientific notation. */
+export function parseAmount(value: string, decimals: number): bigint {
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18) throw new RangeError("Invalid decimal precision");
+ if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value)) throw new RangeError("Use an unsigned decimal amount");
+ const [whole, fraction = ""] = value.split(".");
+ if (fraction.length > decimals) throw new RangeError("Too many decimal places");
+ return bounded(BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction.padEnd(decimals, "0") || "0"), U64_MAX, "Amount");
+}
+export function formatAmount(value: bigint, decimals: number): string {
+ bounded(value, U128_MAX, "Amount");
+ 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+$/, "");
+ 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
new file mode 100644
index 00000000..4ed66149
--- /dev/null
+++ b/app/packages/solana-sdk/src/transactions.ts
@@ -0,0 +1,71 @@
+import { Buffer } from "buffer";
+import { PublicKey, Transaction, VersionedTransaction, type Connection, type TransactionInstruction } from "@solana/web3.js";
+
+export const MAX_TRANSACTION_BYTES = 1232;
+export type PreparedTransaction = {
+ transaction: Transaction; wireBytes: Uint8Array; messageBytes: Uint8Array;
+ blockhash: string; lastValidBlockHeight: number;
+};
+export async function prepareTransaction(connection: Connection, payer: PublicKey, instructions: TransactionInstruction[]): Promise {
+ if (instructions.length === 0) throw new Error("Transaction has no instructions");
+ const latest = await connection.getLatestBlockhash("confirmed");
+ const transaction = new Transaction({ feePayer: payer, recentBlockhash: latest.blockhash }).add(...instructions);
+ const wireBytes = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
+ if (wireBytes.length > MAX_TRANSACTION_BYTES) throw new Error("Transaction exceeds Solana packet budget");
+ return { transaction, wireBytes, messageBytes: transaction.serializeMessage(), ...latest };
+}
+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)}`);
+ return { unitsConsumed: result.value.unitsConsumed, logs: result.value.logs ?? [] };
+}
+
+const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+/** Local encoding avoids trusting a returned RPC signature after ambiguous submission. */
+export function encodeBase58(bytes: Uint8Array): string {
+ let n = 0n;
+ for (const b of bytes) n = n * 256n + BigInt(b);
+ let result = "";
+ while (n > 0n) { result = BASE58[Number(n % 58n)] + result; n /= 58n; }
+ for (const b of bytes) { if (b !== 0) break; result = "1" + result; }
+ return result;
+}
+export type ValidatedSignedTransaction = { bytes: Uint8Array; signature: string };
+/** Reject changed recipients, amounts, blockhashes, added instructions and missing/invalid signatures. */
+export function validateSignedTransaction(expectedMessage: Uint8Array, signedBytes: Uint8Array, payer: PublicKey): ValidatedSignedTransaction {
+ if (signedBytes.length > MAX_TRANSACTION_BYTES || signedBytes.length < 1) throw new Error("Invalid signed transaction size");
+ const transaction = Transaction.from(signedBytes);
+ if (!transaction.feePayer?.equals(payer) || !transaction.serializeMessage().equals(Buffer.from(expectedMessage))) throw new Error("Wallet changed the transaction message");
+ if (!transaction.verifySignatures(true) || !transaction.signature) throw new Error("Wallet returned missing or invalid signatures");
+ 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 async function submitSignedTransaction(connection: Connection, signed: ValidatedSignedTransaction): Promise {
+ try {
+ const returned = await connection.sendRawTransaction(signed.bytes, { skipPreflight: false, preflightCommitment: "confirmed", maxRetries: 0 });
+ 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 {
+ 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 };
+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");
+ const result = await connection.getSignatureStatuses([signature], { searchTransactionHistory: true });
+ const status = result.value[0];
+ if (status) {
+ // A processed/confirmed error may exist only on a fork. Its signed message
+ // can still land successfully elsewhere until finalized or expired.
+ if (status.err) return { status: status.confirmationStatus === "finalized" ? "failed" : "pending", error: status.err };
+ if (status.confirmationStatus === "finalized") return { status: "finalized" };
+ if (status.confirmationStatus === "confirmed") return { status: "confirmed" };
+ return { status: "pending" };
+ }
+ return { status: height > lastValidBlockHeight ? "expired" : "pending" };
+}
diff --git a/app/packages/solana-sdk/src/verification.ts b/app/packages/solana-sdk/src/verification.ts
new file mode 100644
index 00000000..3c476909
--- /dev/null
+++ b/app/packages/solana-sdk/src/verification.ts
@@ -0,0 +1,31 @@
+import { Buffer } from "buffer";
+import { sha256 } from "@noble/hashes/sha256";
+import { PublicKey } from "@solana/web3.js";
+import { programDataAddress } from "./accounts.ts";
+
+export type DeploymentManifest = {
+ schema: 1; cluster: "mainnet-beta" | "devnet"; programId: string; programData: string;
+ genesisHash: string; sourceCommit: string; binarySha256: string;
+ verifiedSlot: number | null; securityReview: string | null; economicReview: string | null; releaseApproved: boolean;
+};
+export function validateDeploymentManifest(input: unknown): DeploymentManifest {
+ if (!input || typeof input !== "object") throw new Error("Invalid manifest");
+ const m = input as Record;
+ if (m.schema !== 1 || !["mainnet-beta", "devnet"].includes(String(m.cluster)) || typeof m.programId !== "string" || typeof m.programData !== "string" || typeof m.genesisHash !== "string" || typeof m.sourceCommit !== "string" || !/^[0-9a-f]{40}$/.test(m.sourceCommit) || typeof m.binarySha256 !== "string" || !/^[0-9a-f]{64}$/.test(m.binarySha256)) throw new Error("Manifest needs exact program, cluster, genesis, source commit and binary hash");
+ const program = new PublicKey(m.programId);
+ 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.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");
+ return m as unknown as DeploymentManifest;
+}
+/** Hash the reviewed ELF, compare every deployed code byte, and reject nonzero trailing deployment padding. */
+export function verifyDeploymentBytes(programData: Uint8Array, reviewedElf: Uint8Array, expectedSha256: string): string {
+ if (reviewedElf.length < 64 || reviewedElf.length > 16 * 1024 * 1024 || !Buffer.from(reviewedElf.subarray(0, 4)).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) throw new Error("Expected a bounded ELF binary");
+ const hash = Buffer.from(sha256(reviewedElf)).toString("hex");
+ if (hash !== expectedSha256) throw new Error("Reviewed binary SHA-256 does not match manifest");
+ if (programData.length < 45 + reviewedElf.length || !Buffer.from(programData.subarray(45, 45 + reviewedElf.length)).equals(Buffer.from(reviewedElf)) || programData.subarray(45 + reviewedElf.length).some((byte) => byte !== 0)) throw new Error("Deployed program bytes differ from reviewed binary");
+ return hash;
+}
diff --git a/app/packages/solana-sdk/tests/abi-fixture.json b/app/packages/solana-sdk/tests/abi-fixture.json
new file mode 100644
index 00000000..6cc216e6
--- /dev/null
+++ b/app/packages/solana-sdk/tests/abi-fixture.json
@@ -0,0 +1,243 @@
+{
+ "note": "Undeployed fixture identity only; no private key or network activity.",
+ "programId": "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr",
+ "creator": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "nonce": "7",
+ "addresses": {
+ "pool": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "bump": 254,
+ "mint": "2ShZMirdm8opBcM2rZJAwfUgZEEuTvcNWY2DKJD1RdBZ",
+ "tokenVault": "79Dm9ZhvNeWyzDdmo7yyEk2k8z8HVSvBwk5u9bmzurVR",
+ "reserve": "6jPwiggjt4ySPQ1eDPU3GMAcoaJ81mE83841FKAaLkF2",
+ "fees": "EuZ2r6wHKFM4Lnu2DwWPqXz3C8UszMBDST9aVobJU3Rv",
+ "mintAuthority": "DL5R1ERrV3TF91R19xbZU4kx7RczY1rZFKnnYB1TbHpV"
+ },
+ "preparedPoolHex": "f19a6d0411b16dbc0100fe070000000000000001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101157110ebda47769ceef46063e8554cd51ffe3222eed5ba2017295c4984c033f400ac23fc06000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070000004578616d706c65020000004558000000000000000000000000000000000000000000000000000000000000000000000000",
+ "instructions": {
+ "prepareLaunch": {
+ "dataHex": "ce212cf6b5b08ef8070000000000000000ac23fc060000000000070000004578616d706c6502000000455800000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ "keys": [
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": true,
+ "writable": false
+ },
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": true,
+ "writable": true
+ },
+ {
+ "address": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "11111111111111111111111111111111",
+ "signer": false,
+ "writable": false
+ }
+ ]
+ },
+ "activateLaunch": {
+ "dataHex": "90449eb63c849ed7",
+ "keys": [
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": true,
+ "writable": true
+ },
+ {
+ "address": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "2ShZMirdm8opBcM2rZJAwfUgZEEuTvcNWY2DKJD1RdBZ",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "79Dm9ZhvNeWyzDdmo7yyEk2k8z8HVSvBwk5u9bmzurVR",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "6jPwiggjt4ySPQ1eDPU3GMAcoaJ81mE83841FKAaLkF2",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "EuZ2r6wHKFM4Lnu2DwWPqXz3C8UszMBDST9aVobJU3Rv",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "DL5R1ERrV3TF91R19xbZU4kx7RczY1rZFKnnYB1TbHpV",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "4ZU2fH7AswhrTMzewvPQsvqxGDDW6ptM9bB1bUDVw5CS",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "11111111111111111111111111111111",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "SysvarRent111111111111111111111111111111111",
+ "signer": false,
+ "writable": false
+ }
+ ]
+ },
+ "cancelPreparation": {
+ "dataHex": "ed04630f79b6aca7",
+ "keys": [
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": true,
+ "writable": false
+ },
+ {
+ "address": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "signer": false,
+ "writable": true
+ }
+ ]
+ },
+ "buyExactIn": {
+ "dataHex": "faea0d7bd59c13ec00ca9a3b000000000a00000000000000e803000000000000",
+ "keys": [
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": true,
+ "writable": true
+ },
+ {
+ "address": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "2ShZMirdm8opBcM2rZJAwfUgZEEuTvcNWY2DKJD1RdBZ",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "79Dm9ZhvNeWyzDdmo7yyEk2k8z8HVSvBwk5u9bmzurVR",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "6jPwiggjt4ySPQ1eDPU3GMAcoaJ81mE83841FKAaLkF2",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "EuZ2r6wHKFM4Lnu2DwWPqXz3C8UszMBDST9aVobJU3Rv",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "3iRym2dakRoLA4BSpkAM6jUhgziKHAwM6RPJ3sc1s7YB",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "11111111111111111111111111111111",
+ "signer": false,
+ "writable": false
+ }
+ ]
+ },
+ "sellExactIn": {
+ "dataHex": "9527de9bd37c981a00ca9a3b000000000a00000000000000e803000000000000",
+ "keys": [
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": true,
+ "writable": true
+ },
+ {
+ "address": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "2ShZMirdm8opBcM2rZJAwfUgZEEuTvcNWY2DKJD1RdBZ",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "79Dm9ZhvNeWyzDdmo7yyEk2k8z8HVSvBwk5u9bmzurVR",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "6jPwiggjt4ySPQ1eDPU3GMAcoaJ81mE83841FKAaLkF2",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "EuZ2r6wHKFM4Lnu2DwWPqXz3C8UszMBDST9aVobJU3Rv",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "3iRym2dakRoLA4BSpkAM6jUhgziKHAwM6RPJ3sc1s7YB",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
+ "signer": false,
+ "writable": false
+ },
+ {
+ "address": "11111111111111111111111111111111",
+ "signer": false,
+ "writable": false
+ }
+ ]
+ },
+ "claimFees": {
+ "dataHex": "52fbe99c0c34b8ca00",
+ "keys": [
+ {
+ "address": "3oEc6AMYxzeawVCkWca76kfKqjb12jqRMqJppMM7gZUg",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "EuZ2r6wHKFM4Lnu2DwWPqXz3C8UszMBDST9aVobJU3Rv",
+ "signer": false,
+ "writable": true
+ },
+ {
+ "address": "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi",
+ "signer": false,
+ "writable": true
+ }
+ ]
+ }
+ }
+}
diff --git a/app/packages/solana-sdk/tests/accounts.test.ts b/app/packages/solana-sdk/tests/accounts.test.ts
new file mode 100644
index 00000000..04226846
--- /dev/null
+++ b/app/packages/solana-sdk/tests/accounts.test.ts
@@ -0,0 +1,58 @@
+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 { discriminator, textField, u8, u16, u64 } from "../src/encoding.ts";
+import { FIXED_SUPPLY } from "../src/math.ts";
+
+const programId = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
+const creator = new PublicKey(new Uint8Array(32).fill(1));
+const nonce = 7n;
+const a = derivePoolAddresses(programId, creator, nonce);
+function account(data: Buffer, owner = programId, lamports = 10000000): AccountInfo { return { data, owner, lamports, executable: false, rentEpoch: 0 }; }
+function poolBytes(active = false): Buffer {
+ return Buffer.concat([discriminator("account", "Pool"), u8(1), u8(active ? 1 : 0), u8(a.bump), u64(nonce), creator.toBuffer(), creator.toBuffer(), a.mint.toBuffer(), u64(30_000_000_000n), u16(0), u8(0), Buffer.alloc(7 * 50), u64(active ? FIXED_SUPPLY : 0n), u64(0n), Buffer.alloc(16), u64(0n), textField("Example", 32, "name"), textField("EX", 10, "symbol"), textField("", 200, "uri", true), Buffer.alloc(32)]);
+}
+test("Pool decoding preserves case-sensitive addresses, empty URI, exact reserves and PDA", () => {
+ const pool = decodePoolAccount(a.pool, account(poolBytes()), programId);
+ assert.equal(pool.status, "prepared"); assert.equal(pool.uri, ""); assert.equal(pool.nonce, 7n);
+ assert.equal(pool.creator.toBase58(), creator.toBase58());
+ assert.equal(decodePoolAccount(a.pool, account(poolBytes(true)), programId).tokenInventory, FIXED_SUPPLY);
+ assert.throws(() => decodePoolAccount(creator, account(poolBytes()), programId), /Noncanonical/);
+ assert.throws(() => decodePoolAccount(a.pool, account(poolBytes(), TOKEN_PROGRAM_ID), programId), /owner/);
+ assert.throws(() => decodePoolAccount(a.pool, account(poolBytes().subarray(0, 40)), programId), /Truncated/);
+ assert.throws(() => decodePoolAccount(a.pool, account(Buffer.concat([poolBytes(), Buffer.from([1])])), programId), /suffix/);
+});
+test("immutable check requires own linked ProgramData, expected loader and null authority", () => {
+ const dataAddress = programDataAddress(programId);
+ const exe = account(Buffer.concat([Buffer.from([2, 0, 0, 0]), dataAddress.toBuffer()]), UPGRADEABLE_LOADER_ID); exe.executable = true;
+ const data = account(Buffer.concat([Buffer.from([3, 0, 0, 0]), u64(123n), Buffer.alloc(33), Buffer.alloc(100)]), UPGRADEABLE_LOADER_ID);
+ assert.equal(decodeImmutableProgram(programId, exe, dataAddress, data).deploymentSlot, 123n);
+ assert.throws(() => decodeImmutableProgram(programId, exe, a.pool, data), /belong/);
+ data.data[12] = 1; assert.throws(() => decodeImmutableProgram(programId, exe, dataAddress, data), /upgrade authority/);
+ data.data[12] = 0; data.owner = creator; assert.throws(() => decodeImmutableProgram(programId, exe, dataAddress, data), /ProgramData/);
+});
+function mintBytes(): Buffer {
+ const b = Buffer.alloc(82); b.writeBigUInt64LE(FIXED_SUPPLY, 36); b[44] = 6; b[45] = 1; return b;
+}
+function tokenBytes(): Buffer {
+ const b = Buffer.alloc(165); a.mint.toBuffer().copy(b, 0); a.pool.toBuffer().copy(b, 32); b.writeBigUInt64LE(FIXED_SUPPLY, 64); b[108] = 1; return b;
+}
+function vaultBytes(kind: number): Buffer { return Buffer.concat([discriminator("account", "SolVault"), a.pool.toBuffer(), u8(kind), u64(1000n)]); }
+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))];
+ const fake = {
+ getAccountInfo: async () => values[0],
+ getMultipleAccountsInfoAndContext: async () => ({ context: { slot: 5 }, value: values }),
+ } as unknown as Connection;
+ assert.equal((await fetchPoolSnapshot(fake, programId, a.pool)).slot, 5);
+ values[2].data.writeUInt32LE(1, 72);
+ await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /custody/);
+ values[2].data.writeUInt32LE(0, 72);
+ values[3].lamports = 1;
+ await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /underfunded/);
+ values[3].lamports = Number.MAX_SAFE_INTEGER + 1;
+ await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /precision/);
+});
diff --git a/app/packages/solana-sdk/tests/instructions.test.ts b/app/packages/solana-sdk/tests/instructions.test.ts
new file mode 100644
index 00000000..d8f817a2
--- /dev/null
+++ b/app/packages/solana-sdk/tests/instructions.test.ts
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Keypair, PublicKey, SystemProgram, Transaction } from "@solana/web3.js";
+import { buildActivateLaunchInstruction, buildBuyInstruction, buildCancelPreparationInstruction, buildClaimFeesInstruction, buildCreateAssociatedTokenInstruction, buildPrepareLaunchInstruction, buildSellInstruction } from "../src/instructions.ts";
+import { derivePoolAddresses, programDataAddress, TOKEN_PROGRAM_ID } from "../src/accounts.ts";
+import { discriminator, Reader } from "../src/encoding.ts";
+import { MAX_TRANSACTION_BYTES } from "../src/transactions.ts";
+
+const programId = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
+const creator = Keypair.fromSeed(new Uint8Array(32).fill(1)).publicKey;
+const base = { creator, nonce: 7n, virtualSol: 30_000_000_000n, feeBps: 0, recipients: [], name: "Example", symbol: "EX", uri: "", contentHash: new Uint8Array(32) };
+function size(...ix: ReturnType[]) { return new Transaction({ feePayer: creator, recentBlockhash: PublicKey.default.toBase58() }).add(...ix).serialize({ requireAllSignatures: false, verifySignatures: false }).length; }
+test("six instruction discriminators, exact args/account order and signer flags", () => {
+ const prepare = buildPrepareLaunchInstruction(programId, base);
+ assert.ok(prepare.data.subarray(0, 8).equals(discriminator("global", "prepare_launch")));
+ const reader = new Reader(prepare.data.subarray(8));
+ assert.equal(reader.long(), 7n); assert.equal(reader.long(), 30_000_000_000n); assert.equal(reader.short(), 0);
+ assert.equal(reader.text(32), "Example"); assert.equal(reader.text(10), "EX"); assert.equal(reader.text(200, true), "");
+ assert.ok(prepare.keys[0].isSigner); assert.ok(prepare.keys[1].isSigner); assert.equal(prepare.keys[0].isWritable, false);
+ const a = derivePoolAddresses(programId, creator, 7n);
+ assert.equal(prepare.keys[2].pubkey.toBase58(), a.pool.toBase58());
+ const activate = buildActivateLaunchInstruction(programId, creator, 7n);
+ assert.equal(activate.keys[8].pubkey.toBase58(), programDataAddress(programId).toBase58());
+ assert.equal(activate.keys[9].pubkey.toBase58(), TOKEN_PROGRAM_ID.toBase58());
+ assert.equal(buildCancelPreparationInstruction(programId, creator, 7n).keys.length, 2);
+ for (const build of [buildBuyInstruction, buildSellInstruction]) {
+ const ix = build(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 1n, expirySlot: 900n });
+ const args = new Reader(ix.data.subarray(8)); assert.equal(args.long(), 10000n); assert.equal(args.long(), 1n); assert.equal(args.long(), 900n);
+ assert.equal(ix.keys.length, 9); assert.ok(ix.keys[0].isSigner); assert.ok(ix.keys[8].pubkey.equals(SystemProgram.programId));
+ }
+ const claim = buildClaimFeesInstruction(programId, { creator, nonce: 7n, recipients: [{ address: creator, weightBps: 10000, paid: 0n }] }, 0);
+ assert.equal(claim.keys.length, 3); assert.ok(claim.keys.every((key) => !key.isSigner)); assert.equal(claim.data[8], 0);
+});
+test("maximum preparation, activation and swap+ATA packets fit 1232 bytes independently", (t) => {
+ const recipients = Array.from({ length: 7 }, (_, i) => ({ address: new PublicKey(new Uint8Array(32).fill(i + 2)), weightBps: i === 6 ? 1432 : 1428 }));
+ const prepare = buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, name: "N".repeat(32), symbol: "S".repeat(10), uri: "https://example.com/" + "x".repeat(180), recipients });
+ const activate = buildActivateLaunchInstruction(programId, creator, 7n);
+ const a = derivePoolAddresses(programId, creator, 7n);
+ const buy = buildBuyInstruction(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 1n, expirySlot: 900n });
+ const sizes = { prepare: size(prepare), activate: size(activate), buyWithAta: size(buildCreateAssociatedTokenInstruction(creator, a.mint, creator), buy) };
+ for (const value of Object.values(sizes)) assert.ok(value <= MAX_TRANSACTION_BYTES);
+ t.diagnostic(JSON.stringify(sizes));
+});
+test("invalid terms, aliases, UTF-8 bounds and zero slippage minimum reject locally", () => {
+ assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, name: "🪙".repeat(9) }));
+ assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, name: " " }));
+ assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, name: "a\u0085b" }));
+ assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, recipients: [{ address: programId, weightBps: 10000 }] }));
+ assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, recipients: [{ address: derivePoolAddresses(programId, creator, 7n).reserve, weightBps: 10000 }] }));
+ assert.throws(() => buildBuyInstruction(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 0n, expirySlot: 900n }));
+});
diff --git a/app/packages/solana-sdk/tests/math.test.ts b/app/packages/solana-sdk/tests/math.test.ts
new file mode 100644
index 00000000..70ff872a
--- /dev/null
+++ b/app/packages/solana-sdk/tests/math.test.ts
@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { claimableFees, feeEntitlement, FIXED_SUPPLY, formatAmount, MAX_VIRTUAL_SOL, MIN_VIRTUAL_SOL, minimumAfterSlippage, parseAmount, quoteBuy, quoteSell, U64_MAX, U128_MAX, validateCurve, type CurveState } from "../src/math.ts";
+// Independent oracle maintained outside SDK: binary searches the invariant rather than using quotient formulas.
+// @ts-expect-error Deliberately dependency-free JavaScript reference implementation.
+import { quoteBuyOracle, quoteSellOracle } from "../../../../packages/solana-model/oracle.mjs";
+
+const initial = (feeBps = 100): CurveState => ({ tokenInventory: FIXED_SUPPLY, realSolReserves: 0n, virtualSol: 30_000_000_000n, feeBps });
+type Golden = { name: string; side: "buy" | "sell"; state: { tokenInventory: string; realSolReserves: string; virtualSol: string; feeBps: number }; input: string; minOutput: string; expected: Record };
+const vectors = JSON.parse(readFileSync(new URL("../../../../packages/solana-model/golden-vectors.json", import.meta.url), "utf8")) as Golden[];
+for (const vector of vectors) test(`independent golden: ${vector.name}`, () => {
+ const state = { ...vector.state, tokenInventory: BigInt(vector.state.tokenInventory), realSolReserves: BigInt(vector.state.realSolReserves), virtualSol: BigInt(vector.state.virtualSol) };
+ const result = (vector.side === "buy" ? quoteBuy : quoteSell)(state, BigInt(vector.input), BigInt(vector.minOutput));
+ for (const [key, value] of Object.entries(vector.expected)) assert.equal(result[key as keyof typeof result], BigInt(value), key);
+});
+
+test("12,000 differential trades match independent binary-search oracle", () => {
+ let seed = 29103;
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed; };
+ for (const feeBps of [0, 100, 300]) {
+ let state = initial(feeBps);
+ for (let i = 0; i < 4000; i++) {
+ const sell = random() % 2 === 0 && state.tokenInventory < FIXED_SUPPLY;
+ const circulating = FIXED_SUPPLY - state.tokenInventory;
+ const input = sell ? circulating * BigInt(random() % 1000 + 1) / 1001n : BigInt(random() % 1_000_000_000 + 10_000);
+ let quote;
+ try { quote = (sell ? quoteSell : quoteBuy)(state, input); }
+ catch { assert.throws(() => (sell ? quoteSellOracle : quoteBuyOracle)(state, input)); continue; }
+ const oracle = (sell ? quoteSellOracle : quoteBuyOracle)(state, input);
+ for (const key of ["amountOut", "fee", "nextTokenInventory", "nextRealSolReserves"] as const) assert.equal(quote[key], oracle[key]);
+ state = { ...state, tokenInventory: quote.nextTokenInventory, realSolReserves: quote.nextRealSolReserves };
+ validateCurve(state);
+ }
+ }
+});
+test("integer boundaries, slippage and underfunded state reject", () => {
+ assert.throws(() => quoteBuy(initial(), 0n));
+ assert.throws(() => quoteBuy(initial(), 1n));
+ assert.throws(() => quoteBuy(initial(0), U64_MAX));
+ assert.doesNotThrow(() => quoteBuy({ ...initial(300), virtualSol: MIN_VIRTUAL_SOL }, U64_MAX));
+ assert.throws(() => quoteBuy(initial(), U64_MAX + 1n));
+ assert.throws(() => quoteBuy({ ...initial(), virtualSol: MAX_VIRTUAL_SOL + 1n }, 1000n));
+ assert.throws(() => quoteBuy({ ...initial(), virtualSol: MIN_VIRTUAL_SOL - 1n }, 1000n));
+ assert.throws(() => quoteBuy(initial(), 10000n, FIXED_SUPPLY));
+ assert.throws(() => quoteSell(initial(), 1n));
+ assert.throws(() => quoteSell({ ...initial(), tokenInventory: FIXED_SUPPLY / 2n }, 1n));
+ assert.throws(() => quoteBuy({ ...initial(), feeBps: 99 }, 10000n));
+});
+test("lifetime fees use full u128 without overflow and cumulative claims leave {
+ for (const total of [1n, 17n, U64_MAX + 50000n, U128_MAX]) {
+ const amounts = [3333, 3333, 3334].map((w) => feeEntitlement(total, w));
+ const dust = total - amounts.reduce((sum, amount) => sum + amount, 0n);
+ assert.ok(dust >= 0n && dust < 3n);
+ assert.equal(claimableFees(total, 3333, amounts[0]), 0n);
+ }
+ assert.throws(() => feeEntitlement(U128_MAX + 1n, 10000));
+ assert.throws(() => claimableFees(1n, 10000, 2n));
+});
+test("user amount handling never loses decimals or accepts exponent notation", () => {
+ assert.equal(parseAmount("1.000000001", 9), 1_000_000_001n);
+ assert.equal(parseAmount("18446744073.709551615", 9), U64_MAX);
+ assert.equal(formatAmount(U64_MAX, 9), "18446744073.709551615");
+ assert.equal(formatAmount(1n, 6), "0.000001");
+ for (const value of ["1e9", "-1", " 1", "+1", "1.", ".5", "01", "0.0000000001", "NaN", "Infinity"]) assert.throws(() => parseAmount(value, 9), value);
+ assert.equal(minimumAfterSlippage(1000n, 50), 995n);
+ assert.throws(() => minimumAfterSlippage(1n, 50));
+});
diff --git a/app/packages/solana-sdk/tests/transactions.test.ts b/app/packages/solana-sdk/tests/transactions.test.ts
new file mode 100644
index 00000000..34c5c6e7
--- /dev/null
+++ b/app/packages/solana-sdk/tests/transactions.test.ts
@@ -0,0 +1,48 @@
+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";
+
+const signer = Keypair.fromSeed(new Uint8Array(32).fill(1));
+const recipient = Keypair.fromSeed(new Uint8Array(32).fill(2)).publicKey;
+function transaction() { return new Transaction({ feePayer: signer.publicKey, recentBlockhash: PublicKey.default.toBase58() }).add(SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 1n })); }
+test("signatures derived locally; changed message, partial signatures, invalid signatures refused", () => {
+ const tx = transaction(); const expected = tx.serializeMessage(); tx.sign(signer);
+ const validated = validateSignedTransaction(expected, tx.serialize(), signer.publicKey);
+ assert.equal(validated.signature, encodeBase58(tx.signature!));
+ const changed = transaction().add(SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 100n })); changed.sign(signer);
+ assert.throws(() => validateSignedTransaction(expected, changed.serialize(), signer.publicKey), /changed/);
+ assert.throws(() => validateSignedTransaction(expected, transaction().serialize({ requireAllSignatures: false, verifySignatures: false }), signer.publicKey), /signatures/);
+ const damaged = tx.serialize(); damaged[2] ^= 1;
+ assert.throws(() => validateSignedTransaction(expected, damaged, signer.publicKey));
+ assert.equal(encodeBase58(new Uint8Array(32)), PublicKey.default.toBase58());
+});
+test("ambiguous submission retains local signature and never creates a replacement", async () => {
+ const tx = transaction(); const expected = tx.serializeMessage(); tx.sign(signer);
+ const signed = validateSignedTransaction(expected, tx.serialize(), signer.publicKey);
+ const fake = { sendRawTransaction: async () => { throw new Error("network timeout"); } } as unknown as Connection;
+ const result = await submitSignedTransaction(fake, signed);
+ assert.equal(result.signature, signed.signature); assert.equal(result.status, "unknown");
+});
+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");
+ height = 101; 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" };
+ assert.equal((await reconcileTransaction(fake, "test", 100)).status, "pending");
+ value = { err: { InstructionError: [0, "Custom"] }, confirmationStatus: "finalized" };
+ assert.equal((await reconcileTransaction(fake, "test", 100)).status, "failed");
+ value = { err: null, confirmationStatus: "finalized" };
+ assert.equal((await reconcileTransaction(fake, "test", 100)).status, "finalized");
+});
+test("preparation reports exact unsigned bytes and enforces packet size", async () => {
+ const fake = { getLatestBlockhash: async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: 100 }) } as unknown as Connection;
+ const ready = await prepareTransaction(fake, signer.publicKey, transaction().instructions);
+ assert.equal(ready.lastValidBlockHeight, 100);
+ assert.deepEqual(ready.messageBytes, ready.transaction.serializeMessage());
+ await assert.rejects(prepareTransaction(fake, signer.publicKey, []), /no instructions/);
+});
diff --git a/app/packages/solana-sdk/tests/verification.test.ts b/app/packages/solana-sdk/tests/verification.test.ts
new file mode 100644
index 00000000..ddef3eeb
--- /dev/null
+++ b/app/packages/solana-sdk/tests/verification.test.ts
@@ -0,0 +1,27 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+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";
+
+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 };
+ 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() }));
+});
+test("deployed ELF requires exact reviewed hash/bytes and only zero allocation padding", () => {
+ const elf = Buffer.alloc(100); elf.set([0x7f, 0x45, 0x4c, 0x46]);
+ const hash = Buffer.from(sha256(elf)).toString("hex");
+ const data = Buffer.concat([Buffer.alloc(45), elf, Buffer.alloc(200)]);
+ assert.equal(verifyDeploymentBytes(data, elf, hash), hash);
+ assert.throws(() => verifyDeploymentBytes(data, elf, "a".repeat(64)), /SHA-256/);
+ data[50] ^= 1; assert.throws(() => verifyDeploymentBytes(data, elf, hash), /differ/); data[50] ^= 1;
+ data[data.length - 1] = 1; assert.throws(() => verifyDeploymentBytes(data, elf, hash), /differ/);
+ assert.throws(() => verifyDeploymentBytes(Buffer.alloc(50), elf, hash), /differ/);
+});
diff --git a/app/packages/solana-sdk/tsconfig.json b/app/packages/solana-sdk/tsconfig.json
new file mode 100644
index 00000000..d787883c
--- /dev/null
+++ b/app/packages/solana-sdk/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler",
+ "strict": true, "skipLibCheck": true, "allowImportingTsExtensions": true,
+ "noEmit": true, "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
+}
From 767e8ba534b04b090ab5f244d91f92700890a317 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Sat, 10 Oct 2026 15:36:40 +0530
Subject: [PATCH 03/11] feat(solana): add gated review workspace and release
checks
Keep the Solana pilot separate and disabled until verified. Add explicit transaction review, wallet-bound recovery, bounded recent history and RPC access, plus an auditable deployment handoff with unresolved mainnet gates.
---
.github/workflows/ci.yml | 33 +
app/.env.example | 8 +
app/Dockerfile | 1 +
app/next.config.ts | 1 +
app/package-lock.json | 662 +++++++++++++++++-
app/package.json | 6 +
.../app/api/solana/history/[pool]/route.ts | 98 +++
app/src/app/api/solana/rpc/route.ts | 157 +++++
app/src/app/solana/page.tsx | 52 ++
app/src/app/solana/pool/[address]/page.tsx | 20 +
app/src/components/solana/LaunchPanel.tsx | 309 ++++++++
app/src/components/solana/PoolPanel.tsx | 578 +++++++++++++++
app/src/components/solana/SolanaHistory.tsx | 238 +++++++
app/src/components/solana/SolanaWorkspace.tsx | 548 +++++++++++++++
app/src/lib/solana/bounded-json.test.ts | 26 +
app/src/lib/solana/bounded-json.ts | 25 +
app/src/lib/solana/config.test.ts | 83 +++
app/src/lib/solana/config.ts | 110 +++
app/src/lib/solana/deployment.json | 13 +
app/src/lib/solana/history.test.ts | 417 +++++++++++
app/src/lib/solana/history.ts | 490 +++++++++++++
app/src/lib/solana/journal.test.ts | 210 ++++++
app/src/lib/solana/journal.ts | 148 ++++
app/src/lib/solana/rpc-budget.test.ts | 18 +
app/src/lib/solana/rpc-budget.ts | 22 +
app/src/lib/solana/rpc-policy.test.ts | 146 ++++
app/src/lib/solana/rpc-policy.ts | 114 +++
app/src/lib/solana/server.ts | 37 +
app/src/lib/solana/useSolanaAction.ts | 476 +++++++++++++
app/src/lib/solana/wallet.ts | 182 +++++
docs/SOLANA_IMMUTABLE_POOL_PLAN.md | 247 +++++++
docs/SOLANA_MAINNET_PLAN.md | 161 +++++
docs/SOLANA_RELEASE.md | 74 ++
docs/SOLANA_WORKSPACE.md | 21 +
34 files changed, 5721 insertions(+), 10 deletions(-)
create mode 100644 app/src/app/api/solana/history/[pool]/route.ts
create mode 100644 app/src/app/api/solana/rpc/route.ts
create mode 100644 app/src/app/solana/page.tsx
create mode 100644 app/src/app/solana/pool/[address]/page.tsx
create mode 100644 app/src/components/solana/LaunchPanel.tsx
create mode 100644 app/src/components/solana/PoolPanel.tsx
create mode 100644 app/src/components/solana/SolanaHistory.tsx
create mode 100644 app/src/components/solana/SolanaWorkspace.tsx
create mode 100644 app/src/lib/solana/bounded-json.test.ts
create mode 100644 app/src/lib/solana/bounded-json.ts
create mode 100644 app/src/lib/solana/config.test.ts
create mode 100644 app/src/lib/solana/config.ts
create mode 100644 app/src/lib/solana/deployment.json
create mode 100644 app/src/lib/solana/history.test.ts
create mode 100644 app/src/lib/solana/history.ts
create mode 100644 app/src/lib/solana/journal.test.ts
create mode 100644 app/src/lib/solana/journal.ts
create mode 100644 app/src/lib/solana/rpc-budget.test.ts
create mode 100644 app/src/lib/solana/rpc-budget.ts
create mode 100644 app/src/lib/solana/rpc-policy.test.ts
create mode 100644 app/src/lib/solana/rpc-policy.ts
create mode 100644 app/src/lib/solana/server.ts
create mode 100644 app/src/lib/solana/useSolanaAction.ts
create mode 100644 app/src/lib/solana/wallet.ts
create mode 100644 docs/SOLANA_IMMUTABLE_POOL_PLAN.md
create mode 100644 docs/SOLANA_MAINNET_PLAN.md
create mode 100644 docs/SOLANA_RELEASE.md
create mode 100644 docs/SOLANA_WORKSPACE.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 63598ba9..b21eb85b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,11 @@ jobs:
- run: npm run lint
- run: npx tsc --noEmit -p .
- run: npm test
+ - name: Solana SDK and independent economic model
+ run: |
+ 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
- name: production build
env:
NEXT_PUBLIC_SITE_URL: https://openlaunch.lol
@@ -39,6 +44,34 @@ jobs:
NEXT_PUBLIC_LAUNCH_LOCKER_ROBINHOOD: "0xcd1680D26922fcd9CabFbb8a56bA40C333fD842a"
run: npm run build
+ solana:
+ name: Solana · host tests, SBFv3 build, offline VM
+ runs-on: ubuntu-latest
+ timeout-minutes: 35
+ defaults:
+ run:
+ working-directory: solana
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: 1.97.1
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: solana
+ - run: cargo fmt --all --check
+ - run: cargo test -p launch-pool --locked
+ - name: Install pinned build-only SBF tool
+ run: cargo install cargo-build-sbf --version 4.2.0 --locked --root .tools
+ - name: Build current SBPF format, reject stack diagnostics
+ run: bash scripts/check-sbf.sh
+ - name: Execute compiled program offline
+ run: cargo test --workspace --features svm-tests --locked
+ - name: Rust lint
+ run: cargo clippy --workspace --all-targets --features svm-tests --locked -- -D warnings
+ # No RPC credentials, wallet keys, deployments, or authority operations in CI.
+
contracts:
name: contracts · build + unit tests
runs-on: ubuntu-latest
diff --git a/app/.env.example b/app/.env.example
index 92461b96..9f36868c 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -52,3 +52,11 @@ IMAGE_STORE=local
# Wallets allowed to moderate posts (comma-separated, lowercase)
ADMIN_WALLETS=
+
+# Isolated Solana review workspace (/solana). Disabled by default; not an EVM chain.
+# Kevin supplies a reviewed, immutable deployment. No keys or signing on the server.
+SOLANA_ENABLED=0
+SOLANA_CLUSTER=devnet
+SOLANA_DEVNET_PROGRAM_ID=
+# HTTPS only. Mainnet requires a keyed RPC and an approved src/lib/solana/deployment.json.
+SOLANA_RPC_URL=
diff --git a/app/Dockerfile b/app/Dockerfile
index 44a85342..6d13a989 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -5,6 +5,7 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
+COPY packages/solana-sdk ./packages/solana-sdk
RUN npm ci --no-audit --no-fund
COPY . .
diff --git a/app/next.config.ts b/app/next.config.ts
index 1e8433ab..e58126e3 100644
--- a/app/next.config.ts
+++ b/app/next.config.ts
@@ -4,6 +4,7 @@ import { SECURITY_HEADERS } from "./src/lib/security-headers";
const nextConfig: NextConfig = {
// Fly: the Dockerfile copies .next/standalone (server.js + traced node_modules).
output: "standalone",
+ transpilePackages: ["@openlaunch/solana-sdk"],
// Keep the Postgres driver external (not bundled) so it is traced into
// .next/standalone/node_modules and scripts/migrate.mjs can import it inside
// the image (release_command runs there, outside the Next server).
diff --git a/app/package-lock.json b/app/package-lock.json
index 905828d0..0796d9ec 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -12,7 +12,13 @@
"@base-ui/react": "^1.8.0",
"@coinbase/wallet-sdk": "^4.3.7",
"@number-flow/react": "^0.6.2",
+ "@openlaunch/solana-sdk": "file:./packages/solana-sdk",
+ "@solana/wallet-standard-features": "1.5.0",
+ "@solana/web3.js": "1.99.0",
"@tanstack/react-query": "^5.91.3",
+ "@wallet-standard/app": "1.1.1",
+ "@wallet-standard/base": "1.1.1",
+ "@wallet-standard/features": "1.1.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"lightweight-charts": "^5.2.1",
@@ -2357,6 +2363,10 @@
"react-dom": "^18 || ^19"
}
},
+ "node_modules/@openlaunch/solana-sdk": {
+ "resolved": "packages/solana-sdk",
+ "link": true
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2481,6 +2491,119 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@solana/buffer-layout": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz",
+ "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "~6.0.3"
+ },
+ "engines": {
+ "node": ">=5.10"
+ }
+ },
+ "node_modules/@solana/codecs-core": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz",
+ "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "5.5.1"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/codecs-numbers": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz",
+ "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/codecs-core": "5.5.1",
+ "@solana/errors": "5.5.1"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/errors": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz",
+ "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "5.6.2",
+ "commander": "14.0.2"
+ },
+ "bin": {
+ "errors": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/wallet-standard-features": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.5.0.tgz",
+ "integrity": "sha512-gxlLaEfPAqbhd3LNkK0Ks2pbVvDm1SDGqBK/ynDQy13Gu+qiURLpRCfSPjbtrw5bmqQsZ6rS2YJHL31bY/Koag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@wallet-standard/base": "^1.1.0",
+ "@wallet-standard/features": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@solana/web3.js": {
+ "version": "1.99.0",
+ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.99.0.tgz",
+ "integrity": "sha512-QZYQ2T1z6xWisoyALPq25i/QZTsRlM02BABtAsfaQ1p8wX4SdTfxrKTRue/ZZqrhNVh5oL7T/DUFiTS9DRgxow==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "@noble/curves": "^1.9.7",
+ "@noble/hashes": "^1.8.0",
+ "@solana/buffer-layout": "^4.0.1",
+ "@solana/codecs-numbers": "^5.5.1",
+ "agentkeepalive": "^4.6.0",
+ "bn.js": "^5.2.5",
+ "borsh": "^0.7.0",
+ "bs58": "^4.0.1",
+ "buffer": "6.0.3",
+ "fast-stable-stringify": "^1.0.0",
+ "jayson": "^4.3.0",
+ "node-fetch": "^2.7.0",
+ "rpc-websockets": "^9.0.2",
+ "superstruct": "^2.0.2"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -2810,6 +2933,15 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
@@ -2842,19 +2974,11 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/node/node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -2875,6 +2999,21 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
+ "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
@@ -3524,6 +3663,39 @@
"win32"
]
},
+ "node_modules/@wallet-standard/app": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@wallet-standard/app/-/app-1.1.1.tgz",
+ "integrity": "sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@wallet-standard/base": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@wallet-standard/base": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz",
+ "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@wallet-standard/features": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz",
+ "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@wallet-standard/base": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -3547,6 +3719,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3800,6 +3984,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base-x": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
+ "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.11.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz",
@@ -3812,6 +4025,23 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bn.js": {
+ "version": "5.2.5",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz",
+ "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==",
+ "license": "MIT"
+ },
+ "node_modules/borsh": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz",
+ "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bn.js": "^5.2.0",
+ "bs58": "^4.0.0",
+ "text-encoding-utf-8": "^1.0.2"
+ }
+ },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@@ -3876,6 +4106,53 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bs58": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
+ "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
+ "license": "MIT",
+ "dependencies": {
+ "base-x": "^3.0.2"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/bufferutil": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
+ "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=6.14.2"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3956,6 +4233,18 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3991,6 +4280,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/commander": {
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
+ "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4190,6 +4488,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/delay": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
+ "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4454,6 +4764,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es6-promise": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
+ "license": "MIT"
+ },
+ "node_modules/es6-promisify": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
+ "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es6-promise": "^4.0.3"
+ }
+ },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -4979,6 +5304,14 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
+ "node_modules/eyes": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
+ "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==",
+ "engines": {
+ "node": "> 0.1.90"
+ }
+ },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
@@ -5036,6 +5369,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-stable-stringify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz",
+ "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==",
+ "license": "MIT"
+ },
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
@@ -5472,6 +5811,35 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5969,6 +6337,15 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isomorphic-ws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
+ "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "ws": "*"
+ }
+ },
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
@@ -6002,6 +6379,65 @@
"node": ">= 0.4"
}
},
+ "node_modules/jayson": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz",
+ "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "^3.4.33",
+ "@types/node": "^12.12.54",
+ "@types/ws": "^7.4.4",
+ "commander": "^2.20.3",
+ "delay": "^5.0.0",
+ "es6-promisify": "^5.0.0",
+ "eyes": "^0.1.8",
+ "isomorphic-ws": "^4.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "stream-json": "^1.9.1",
+ "uuid": "^8.3.2",
+ "ws": "^7.5.10"
+ },
+ "bin": {
+ "jayson": "bin/jayson.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jayson/node_modules/@types/node": {
+ "version": "12.20.55",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
+ "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
+ "license": "MIT"
+ },
+ "node_modules/jayson/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "license": "MIT"
+ },
+ "node_modules/jayson/node_modules/ws": {
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -6069,6 +6505,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -6607,7 +7049,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -6761,6 +7202,38 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@@ -7353,6 +7826,51 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rpc-websockets": {
+ "version": "9.3.9",
+ "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz",
+ "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==",
+ "license": "LGPL-3.0-only",
+ "dependencies": {
+ "@swc/helpers": "^0.5.11",
+ "@types/uuid": "^10.0.0",
+ "@types/ws": "^8.2.2",
+ "buffer": "^6.0.3",
+ "eventemitter3": "^5.0.1",
+ "uuid": "^14.0.0",
+ "ws": "^8.5.0"
+ },
+ "funding": {
+ "type": "paypal",
+ "url": "https://paypal.me/kozjak"
+ },
+ "optionalDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^6.0.0"
+ }
+ },
+ "node_modules/rpc-websockets/node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/rpc-websockets/node_modules/uuid": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz",
+ "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7397,6 +7915,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -7693,6 +8231,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/stream-chain": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
+ "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stream-json": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
+ "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "stream-chain": "^2.2.5"
+ }
+ },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -7853,6 +8406,15 @@
}
}
},
+ "node_modules/superstruct": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz",
+ "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7910,6 +8472,11 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/text-encoding-utf-8": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
+ "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg=="
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -7971,6 +8538,12 @@
"node": ">=8.0"
}
},
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -8183,6 +8756,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -8271,6 +8850,30 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/utf-8-validate": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz",
+ "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=6.14.2"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/viem": {
"version": "2.55.19",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.55.19.tgz",
@@ -8461,6 +9064,22 @@
}
}
},
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8581,7 +9200,6 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -8669,6 +9287,30 @@
"optional": true
}
}
+ },
+ "packages/solana-sdk": {
+ "name": "@openlaunch/solana-sdk",
+ "version": "0.1.0",
+ "dependencies": {
+ "@noble/hashes": "1.8.0",
+ "@solana/web3.js": "1.99.0",
+ "buffer": "6.0.3"
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "tsx": "4.23.13",
+ "typescript": "5.9.3"
+ }
+ },
+ "packages/solana-sdk/node_modules/@types/node": {
+ "version": "22.20.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
+ "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
}
}
}
diff --git a/app/package.json b/app/package.json
index 9dee25df..09102804 100644
--- a/app/package.json
+++ b/app/package.json
@@ -12,6 +12,12 @@
"migrate": "node scripts/migrate.mjs"
},
"dependencies": {
+ "@openlaunch/solana-sdk": "file:./packages/solana-sdk",
+ "@solana/web3.js": "1.99.0",
+ "@solana/wallet-standard-features": "1.5.0",
+ "@wallet-standard/app": "1.1.1",
+ "@wallet-standard/base": "1.1.1",
+ "@wallet-standard/features": "1.1.1",
"@aws-sdk/client-s3": "^3.1127.0",
"@base-ui/react": "^1.8.0",
"@coinbase/wallet-sdk": "^4.3.7",
diff --git a/app/src/app/api/solana/history/[pool]/route.ts b/app/src/app/api/solana/history/[pool]/route.ts
new file mode 100644
index 00000000..e6a314c2
--- /dev/null
+++ b/app/src/app/api/solana/history/[pool]/route.ts
@@ -0,0 +1,98 @@
+import { PublicKey } from "@solana/web3.js";
+import { fetchPoolSnapshot } from "../../../../../../packages/solana-sdk/src/accounts";
+import { publicKey } from "@/lib/solana/config";
+import {
+ createHistoryCache,
+ fetchRecentSolanaHistory,
+ type SolanaHistory,
+} from "@/lib/solana/history";
+import { configuredSolana, solanaConnection } from "@/lib/solana/server";
+
+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" };
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ pool: string }> },
+) {
+ const config = configuredSolana();
+ if (!config)
+ return Response.json(
+ { error: "Solana is not enabled." },
+ { status: 503, headers: noStore },
+ );
+ const { pool } = await params;
+ if (!publicKey(pool))
+ return Response.json(
+ { 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)
+ 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 },
+ );
+ 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,
+ );
+ },
+ );
+ return Response.json(
+ { ...history, cluster: config.cluster },
+ {
+ headers: {
+ "cache-control":
+ "public, max-age=0, s-maxage=10, stale-while-revalidate=15",
+ },
+ },
+ );
+ } catch (error) {
+ const busy = error instanceof Error && error.message === "HISTORY_BUSY";
+ return Response.json(
+ {
+ error: busy
+ ? "Recent history is busy. Please retry shortly."
+ : "Verified recent history is unavailable. No chart data has been substituted.",
+ },
+ {
+ status: busy ? 429 : 503,
+ headers: { ...noStore, "retry-after": "15" },
+ },
+ );
+ }
+}
diff --git a/app/src/app/api/solana/rpc/route.ts b/app/src/app/api/solana/rpc/route.ts
new file mode 100644
index 00000000..365453fa
--- /dev/null
+++ b/app/src/app/api/solana/rpc/route.ts
@@ -0,0 +1,157 @@
+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";
+
+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();
+
+export async function POST(req: Request) {
+ const config = configuredSolana();
+ const upstream = solanaRpcUrl();
+ if (!config || !upstream)
+ return Response.json(
+ { error: "Solana is not enabled." },
+ { status: 503, headers },
+ );
+ const origin = req.headers.get("origin");
+ if (origin && origin !== new URL(req.url).origin)
+ 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)
+ 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 },
+ );
+ let body: unknown;
+ try {
+ body = await boundedJson(req.body, 12_000);
+ } catch {
+ return Response.json(
+ { error: "Invalid request." },
+ { status: 400, headers },
+ );
+ }
+ if (!allowedSolanaRpc(body, config.programId))
+ return Response.json(
+ {
+ jsonrpc: "2.0",
+ id: typeof body === "object" && body && "id" in body ? body.id : null,
+ error: { code: -32601, message: "Method or parameters not allowed." },
+ },
+ { headers },
+ );
+ const release = acquire();
+ if (!release)
+ return Response.json(
+ { error: "Solana RPC is busy. Please retry shortly." },
+ { status: 429, headers: { ...headers, "retry-after": "10" } },
+ );
+ try {
+ if (
+ !network ||
+ network.url !== upstream ||
+ network.cluster !== config.cluster ||
+ network.expires < now
+ ) {
+ const check = await fetch(upstream, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "getGenesisHash",
+ params: [],
+ }),
+ signal: AbortSignal.timeout(8_000),
+ cache: "no-store",
+ });
+ const result = (await boundedJson(check.body, 4_096)) as {
+ result?: unknown;
+ };
+ if (!check.ok || result.result !== GENESIS[config.cluster])
+ throw new Error("Wrong cluster");
+ network = {
+ url: upstream,
+ cluster: config.cluster,
+ expires: now + 30_000,
+ };
+ }
+ const response = await fetch(upstream, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ cache: "no-store",
+ });
+ if (!response.ok) throw new Error("Upstream unavailable");
+ const result = (await boundedJson(response.body, 4 * 1024 * 1024)) as {
+ jsonrpc?: unknown;
+ id?: unknown;
+ result?: unknown;
+ error?: unknown;
+ };
+ if (
+ !result ||
+ result.jsonrpc !== "2.0" ||
+ result.id !== body.id ||
+ (!("result" in result) && !("error" in result))
+ )
+ throw new Error("Invalid RPC response");
+ if (result.error)
+ return Response.json(
+ {
+ jsonrpc: "2.0",
+ id: body.id,
+ error: {
+ code: -32000,
+ message:
+ "Solana RPC rejected this request. Check the transaction status before retrying.",
+ },
+ },
+ { headers },
+ );
+ return Response.json(result, { headers });
+ } catch {
+ network = null;
+ return Response.json(
+ {
+ jsonrpc: "2.0",
+ id: body.id,
+ error: {
+ code: -32000,
+ message:
+ "Solana RPC is unavailable or on the wrong network. A submitted transaction may still confirm; check its signature before trying again.",
+ },
+ },
+ { status: 503, headers },
+ );
+ } finally {
+ release();
+ }
+}
diff --git a/app/src/app/solana/page.tsx b/app/src/app/solana/page.tsx
new file mode 100644
index 00000000..27e43442
--- /dev/null
+++ b/app/src/app/solana/page.tsx
@@ -0,0 +1,52 @@
+import Link from "next/link";
+import { configuredSolana, solanaRpcUrl } from "@/lib/solana/server";
+import SolanaWorkspace from "@/components/solana/SolanaWorkspace";
+import { btn } from "@/components/ui";
+
+export const dynamic = "force-dynamic";
+export const metadata = {
+ title: "Solana workspace",
+ robots: { index: false, follow: false },
+};
+
+export default function SolanaPage() {
+ const config = configuredSolana();
+ if (config && solanaRpcUrl()) return ;
+ return (
+
+
+ Solana, with no admin controls.
+
+
+ The native launch-pool integration is under review. It is separate from
+ the live launchpad and cannot accept funds here yet.
+
+
+
+ Before launches open
+
+
+
+ Independent security and economic review of the exact program.
+
+
+ Verified deployment with the upgrade authority permanently removed.
+
+
+ Launch, buy, sell, fee-claim and wallet recovery rehearsal on the
+ deployed program.
+
+
+
+
+ Existing Base, Robinhood and Arc launches continue unchanged.
+
+
+ Back to launchpad
+
+
+ );
+}
diff --git a/app/src/app/solana/pool/[address]/page.tsx b/app/src/app/solana/pool/[address]/page.tsx
new file mode 100644
index 00000000..e73cfb61
--- /dev/null
+++ b/app/src/app/solana/pool/[address]/page.tsx
@@ -0,0 +1,20 @@
+import { notFound } from "next/navigation";
+import SolanaWorkspace from "@/components/solana/SolanaWorkspace";
+import { configuredSolana, solanaRpcUrl } from "@/lib/solana/server";
+import { publicKey } from "@/lib/solana/config";
+
+export const dynamic = "force-dynamic";
+export const metadata = {
+ title: "Solana pool",
+ robots: { index: false, follow: false },
+};
+export default async function PoolPage({
+ params,
+}: {
+ params: Promise<{ address: string }>;
+}) {
+ const { address } = await params;
+ const config = configuredSolana();
+ if (!config || !solanaRpcUrl() || !publicKey(address)) notFound();
+ return ;
+}
diff --git a/app/src/components/solana/LaunchPanel.tsx b/app/src/components/solana/LaunchPanel.tsx
new file mode 100644
index 00000000..c7178110
--- /dev/null
+++ b/app/src/components/solana/LaunchPanel.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useState } from "react";
+import { PublicKey } from "@solana/web3.js";
+import {
+ buildPrepareLaunchInstruction,
+ derivePoolAddresses,
+ POOL_ACCOUNT_SPACE,
+} from "@openlaunch/solana-sdk";
+import { btn, input, label, helper } from "@/components/ui";
+import { formatUnits, parseUnits } from "@/lib/solana/config";
+import type { SolanaPanelProps } from "./SolanaWorkspace";
+
+export default function LaunchPanel({
+ connection,
+ config,
+ address,
+ actions,
+}: SolanaPanelProps) {
+ const [name, setName] = useState("");
+ const [symbol, setSymbol] = useState("");
+ const [valuation, setValuation] = useState("");
+ const [feeBps, setFeeBps] = useState(0);
+ const [recipients, setRecipients] = useState("");
+ const [uri, setUri] = useState("");
+ const [hash, setHash] = useState("");
+ const [acknowledged, setAcknowledged] = useState(false);
+ const [preparing, setPreparing] = useState(false);
+
+ async function prepare() {
+ if (
+ !address ||
+ preparing ||
+ !acknowledged ||
+ actions.busy ||
+ actions.pending ||
+ !actions.restored
+ )
+ return;
+ setPreparing(true);
+ try {
+ const virtualSol = parseUnits(valuation, 9);
+ const creator = new PublicKey(address);
+ const program = new PublicKey(config.programId);
+ const nonce = new DataView(
+ crypto.getRandomValues(new Uint8Array(8)).buffer,
+ ).getBigUint64(0, true);
+ const contentHash = new Uint8Array(32);
+ if (hash && !/^[a-fA-F0-9]{64}$/.test(hash))
+ throw new Error(
+ "The metadata SHA-256 must be exactly 64 hexadecimal characters.",
+ );
+ if (hash)
+ for (let i = 0; i < 32; i++)
+ contentHash[i] = parseInt(hash.slice(i * 2, i * 2 + 2), 16);
+ if (uri && !/^https:\/\//.test(uri))
+ throw new Error("Use an HTTPS metadata URI, or leave it empty.");
+ if (Boolean(uri) !== Boolean(hash))
+ throw new Error(
+ "Supply both the metadata URI and its content hash, or leave both empty.",
+ );
+ const beneficiaries =
+ feeBps === 0
+ ? []
+ : recipients
+ .trim()
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .map((line) => {
+ const [wallet, weight, extra] = line.trim().split(/\s+/);
+ if (extra || !/^\d+$/.test(weight ?? ""))
+ throw new Error(
+ "Use one recipient per line: wallet-address share-in-basis-points.",
+ );
+ return {
+ address: new PublicKey(wallet),
+ weightBps: Number(weight),
+ };
+ });
+ const ix = buildPrepareLaunchInstruction(program, {
+ creator,
+ nonce,
+ virtualSol,
+ feeBps,
+ name,
+ symbol,
+ uri,
+ contentHash,
+ recipients: beneficiaries,
+ });
+ 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);
+ await actions.prepare(
+ "Prepare launch",
+ [ix],
+ [
+ ["Name / symbol", `${name} / ${symbol}`],
+ ["Token mint", addresses.mint.toBase58()],
+ ["Original supply", "1,000,000,000 tokens · 6 decimals"],
+ [
+ "Initial valuation (not a SOL deposit)",
+ `${formatUnits(virtualSol, 9)} SOL`,
+ ],
+ ["Initial real SOL reserve", "0 SOL"],
+ ["Fixed trading fee", `${feeBps / 100}%`],
+ ...beneficiaries.map((r, index): [string, string] => [
+ `Beneficiary ${index + 1}`,
+ `${r.address.toBase58()} · ${r.weightBps / 100}% of trading fees`,
+ ]),
+ ...(uri
+ ? ([
+ ["Metadata URI (immutable)", uri],
+ ["Metadata SHA-256", hash],
+ ] as [string, string][])
+ : []),
+ [
+ "Preparation rent (estimated, permanent)",
+ `${formatUnits(BigInt(rent), 9)} SOL`,
+ ],
+ ],
+ pool,
+ );
+ } catch (e) {
+ actions.setError(
+ e instanceof Error
+ ? e.message
+ : "Check the launch terms and try again.",
+ );
+ } finally {
+ setPreparing(false);
+ }
+ }
+ return (
+
+
+
+ Two deliberate steps
+
+ Preparation records your immutable terms and reserves a pool address.
+ It does not mint tokens or open trading.
+
+
+ Activation mints the full supply into the pool and removes mint
+ authority in the same transaction. There is no creator allocation and
+ no freeze authority.
+
+
+ Keep the pool address. You can reopen it with the same wallet to
+ activate or cancel a prepared launch. Cancelled addresses cannot be
+ reused.
+
+
+
+ );
+}
diff --git a/app/src/components/solana/PoolPanel.tsx b/app/src/components/solana/PoolPanel.tsx
new file mode 100644
index 00000000..ffcfa57e
--- /dev/null
+++ b/app/src/components/solana/PoolPanel.tsx
@@ -0,0 +1,578 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { PublicKey } from "@solana/web3.js";
+import {
+ buildActivateLaunchInstruction,
+ buildCancelPreparationInstruction,
+ buildBuyInstruction,
+ buildSellInstruction,
+ buildClaimFeesInstruction,
+ buildCreateAssociatedTokenInstruction,
+ claimableFees,
+ deriveAssociatedTokenAddress,
+ fetchPoolSnapshot,
+ FIXED_SUPPLY,
+ minimumAfterSlippage,
+ quoteBuy,
+ quoteSell,
+ 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 type { SolanaPanelProps } from "./SolanaWorkspace";
+import SolanaHistory from "./SolanaHistory";
+
+export default function PoolPanel({
+ connection,
+ config,
+ address,
+ actions,
+ snapshot,
+ onRefresh,
+}: SolanaPanelProps & { snapshot: PoolSnapshot; onRefresh: () => void }) {
+ const { pool } = snapshot;
+ const [side, setSide] = useState<"buy" | "sell">("buy");
+ const [amount, setAmount] = useState("");
+ const [balanceRecord, setBalance] = useState<{
+ wallet: string;
+ mint: string;
+ sol: bigint;
+ tokens: bigint;
+ } | null>(null);
+ const [working, setWorking] = useState(false);
+ const [fresh, setFresh] = useState(snapshot);
+ const [quoteError, setQuoteError] = useState("");
+ const [lastRead, setLastRead] = useState(0);
+ const program = useMemo(
+ () => new PublicKey(config.programId),
+ [config.programId],
+ );
+ const locked =
+ actions.busy ||
+ Boolean(actions.pending) ||
+ !actions.restored ||
+ !address ||
+ working;
+ const isCreator = address === pool.creator.toBase58();
+ useEffect(() => {
+ let alive = true;
+ let reading = false;
+ const load = async () => {
+ if (reading) return;
+ reading = true;
+ try {
+ const result = await fetchPoolSnapshot(
+ connection,
+ program,
+ snapshot.address,
+ );
+ 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 (!Number.isSafeInteger(sol))
+ throw new Error(
+ "Balance precision is unsupported by this RPC client.",
+ );
+ if (
+ token &&
+ (!token.owner.equals(TOKEN_PROGRAM_ID) ||
+ token.data.length !== 165 ||
+ !new PublicKey(token.data.subarray(32, 64)).equals(owner) ||
+ !new PublicKey(token.data.subarray(0, 32)).equals(
+ result.addresses.mint,
+ ))
+ )
+ throw new Error("Invalid trade token account.");
+ if (alive)
+ setBalance({
+ wallet: address,
+ mint: result.addresses.mint.toBase58(),
+ sol: BigInt(sol),
+ tokens: token?.data.readBigUInt64LE(64) ?? 0n,
+ });
+ } else if (alive) setBalance(null);
+ } catch {
+ if (alive) {
+ setBalance(null);
+ setQuoteError(
+ "Live account refresh failed. Trading is paused here until the next successful read.",
+ );
+ }
+ } finally {
+ reading = false;
+ }
+ };
+ void load();
+ const timer = setInterval(() => {
+ if (!document.hidden) void load();
+ }, 8_000);
+ return () => {
+ alive = false;
+ clearInterval(timer);
+ };
+ }, [connection, program, snapshot, address]);
+ let estimated: ReturnType | null = null;
+ let amountError = "";
+ if (amount && fresh.pool.status === "active") {
+ try {
+ estimated =
+ side === "buy"
+ ? quoteBuy(fresh.pool, parseUnits(amount, 9))
+ : quoteSell(fresh.pool, parseUnits(amount, 6));
+ } catch (e) {
+ amountError =
+ e instanceof Error ? e.message : "No quote for this amount.";
+ }
+ }
+ async function reviewTrade() {
+ if (!address || working) return;
+ setWorking(true);
+ try {
+ const current = await fetchPoolSnapshot(
+ connection,
+ program,
+ snapshot.address,
+ );
+ if (current.pool.status !== "active")
+ throw new Error("This pool is not active.");
+ const amountIn = parseUnits(amount, side === "buy" ? 9 : 6);
+ const quote =
+ side === "buy"
+ ? 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 args = {
+ creator: current.pool.creator,
+ nonce: current.pool.nonce,
+ trader,
+ amountIn,
+ minimumOut,
+ expirySlot,
+ };
+ const instructions =
+ side === "buy"
+ ? [
+ buildCreateAssociatedTokenInstruction(
+ trader,
+ current.addresses.mint,
+ trader,
+ ),
+ 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)
+ : 0;
+ await actions.prepare(
+ side === "buy" ? "Buy tokens" : "Sell tokens",
+ instructions,
+ [
+ ["Token mint", current.addresses.mint.toBase58()],
+ [
+ "You send",
+ `${formatUnits(amountIn, side === "buy" ? 9 : 6)} ${side === "buy" ? "SOL" : pool.symbol}`,
+ ],
+ [
+ "Estimated receive",
+ `${formatUnits(quote.amountOut, side === "buy" ? 6 : 9)} ${side === "buy" ? pool.symbol : "SOL"}`,
+ ],
+ [
+ "Minimum receive (1% slippage)",
+ `${formatUnits(minimumOut, side === "buy" ? 6 : 9)} ${side === "buy" ? pool.symbol : "SOL"}`,
+ ],
+ ["Trading fee (included)", `${formatUnits(quote.fee, 9)} SOL`],
+ [
+ "Token account rent (extra, estimated)",
+ `${formatUnits(BigInt(extraRent), 9)} SOL`,
+ ],
+ ["Expiry slot", expirySlot.toString()],
+ ],
+ snapshot.address.toBase58(),
+ );
+ } catch (e) {
+ actions.setError(
+ e instanceof Error ? e.message : "Could not quote this trade.",
+ );
+ } finally {
+ setWorking(false);
+ }
+ }
+ async function reviewLifecycle(activate: boolean) {
+ if (!address || working) return;
+ setWorking(true);
+ try {
+ const current = await fetchPoolSnapshot(
+ connection,
+ program,
+ snapshot.address,
+ );
+ if (
+ current.pool.status !== "prepared" ||
+ current.pool.creator.toBase58() !== address
+ )
+ throw new Error(
+ "Only the original creator can finish this preparation.",
+ );
+ const creator = new PublicKey(address);
+ const rent = activate
+ ? (
+ await Promise.all(
+ [82, 165, 49, 49].map((space) =>
+ connection.getMinimumBalanceForRentExemption(space),
+ ),
+ )
+ ).reduce((sum, n) => sum + n, 0)
+ : 0;
+ await actions.prepare(
+ activate ? "Activate launch" : "Cancel preparation",
+ [
+ activate
+ ? buildActivateLaunchInstruction(program, creator, pool.nonce)
+ : buildCancelPreparationInstruction(program, creator, pool.nonce),
+ ],
+ [
+ ["Token", `${pool.name} (${pool.symbol})`],
+ ["Token mint", current.addresses.mint.toBase58()],
+ [
+ "Effect",
+ activate
+ ? "Mint all supply into the pool; revoke mint authority; open trading permanently."
+ : "Permanently cancel this pool address. Preparation rent is not refunded.",
+ ],
+ [
+ "Additional account rent (estimated)",
+ `${formatUnits(BigInt(rent), 9)} SOL`,
+ ],
+ ["Initial real SOL", "0 SOL"],
+ ["Fixed trading fee", `${pool.feeBps / 100}%`],
+ ],
+ snapshot.address.toBase58(),
+ );
+ } catch (e) {
+ actions.setError(
+ e instanceof Error ? e.message : "Could not review this launch.",
+ );
+ } finally {
+ setWorking(false);
+ }
+ }
+ async function claim(index: number) {
+ if (working) return;
+ setWorking(true);
+ try {
+ const current = await fetchPoolSnapshot(
+ connection,
+ program,
+ snapshot.address,
+ );
+ const recipient = current.pool.recipients[index];
+ const available = claimableFees(
+ current.pool.feesEarned,
+ recipient.weightBps,
+ recipient.paid,
+ );
+ if (!available)
+ throw new Error("There are no fees to claim for this recipient.");
+ await actions.prepare(
+ "Claim fees",
+ [buildClaimFeesInstruction(program, current.pool, index)],
+ [
+ ["Token mint", current.addresses.mint.toBase58()],
+ ["Recipient (fixed)", recipient.address.toBase58()],
+ ["Claimable at review", `${formatUnits(available, 9)} SOL`],
+ [
+ "Your role",
+ "Pay the network fee. SOL is paid only to the fixed recipient.",
+ ],
+ ],
+ snapshot.address.toBase58(),
+ );
+ } catch (e) {
+ actions.setError(
+ e instanceof Error ? e.message : "Could not review this claim.",
+ );
+ } finally {
+ setWorking(false);
+ }
+ }
+ const fdvLamports =
+ (FIXED_SUPPLY * (fresh.pool.realSolReserves + fresh.pool.virtualSol)) /
+ (fresh.pool.tokenInventory || FIXED_SUPPLY);
+ const balance =
+ balanceRecord?.wallet === address &&
+ balanceRecord?.mint === snapshot.addresses.mint.toBase58()
+ ? balanceRecord
+ : null;
+ return (
+
+
+ {pool.status === "prepared" && (
+
+
+ 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.
+
+
+ void reviewLifecycle(true)}
+ >
+ Review activation
+
+ void reviewLifecycle(false)}
+ >
+ Review cancellation
+
+
+ {!isCreator && (
+
+ Connect the creator wallet to activate or cancel.
+
+ )}
+
+ )}
+
+
+
+ {[
+ [
+ "Real SOL reserves",
+ `${formatUnits(fresh.pool.realSolReserves, 9)} SOL`,
+ ],
+ [
+ "Spot-price FDV (not cash available)",
+ `${formatUnits(fdvLamports, 9)} SOL`,
+ ],
+ [
+ "Virtual pricing offset (not deposited)",
+ `${formatUnits(pool.virtualSol, 9)} SOL`,
+ ],
+ [
+ "Pool token inventory",
+ `${formatUnits(fresh.pool.tokenInventory, 6)} ${pool.symbol}`,
+ ],
+ ["Original token supply", "1,000,000,000"],
+ ["Fixed trading fee", `${pool.feeBps / 100}%`],
+ ].map(([term, value]) => (
+
+
{term}
+
+ {value}
+
+
+ ))}
+
+
+ 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" && (
+
{
+ e.preventDefault();
+ void reviewTrade();
+ }}
+ >
+
+ Trade direction
+
+ {(["buy", "sell"] as const).map((value) => (
+ {
+ setSide(value);
+ setAmount("");
+ }}
+ >
+ {value === "buy" ? "Buy" : "Sell"}
+
+ ))}
+
+
+
+
+ You send {side === "buy" ? "SOL" : pool.symbol}
+
+ setAmount(e.target.value)}
+ placeholder="0.00"
+ />
+
+
+ {balance
+ ? `${side === "buy" ? "SOL balance" : "Trade-account balance"}: ${formatUnits(side === "buy" ? balance.sol : balance.tokens, side === "buy" ? 9 : 6)}`
+ : address
+ ? "Reading wallet balances…"
+ : "Connect a wallet to see balances."}
+
+
+
Estimated receive
+
+ {estimated
+ ? formatUnits(estimated.amountOut, side === "buy" ? 6 : 9)
+ : "Enter an amount"}{" "}
+ {estimated ? (side === "buy" ? pool.symbol : "SOL") : ""}
+
+ {estimated && (
+
+ Includes {formatUnits(estimated.fee, 9)} SOL trading fee.
+ Network fee and account rent are extra.
+
+ )}
+
+ {(quoteError || amountError) && (
+
+ {quoteError || amountError}
+
+ )}
+
+ {working ? "Refreshing quote…" : "Review trade"}
+
+
+ 1% maximum output slippage. Final review refreshes the pool and
+ simulates the exact transaction. Keep SOL for fees.
+
+
+ Refresh account state
+
+
+ )}
+
+ {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.
+
+
+ {fresh.pool.recipients.map((r, i) => (
+
+
+
+ {r.address.toBase58()}
+
+
+ {r.weightBps / 100}% share ·{" "}
+ {formatUnits(
+ claimableFees(fresh.pool.feesEarned, r.weightBps, r.paid),
+ 9,
+ )}{" "}
+ SOL claimable
+
+
+ void claim(i)}
+ >
+ Review claim
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/app/src/components/solana/SolanaHistory.tsx b/app/src/components/solana/SolanaHistory.tsx
new file mode 100644
index 00000000..24125541
--- /dev/null
+++ b/app/src/components/solana/SolanaHistory.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useTheme } from "next-themes";
+import {
+ CandlestickSeries,
+ ColorType,
+ createChart,
+ type UTCTimestamp,
+} from "lightweight-charts";
+import {
+ explorerUrl,
+ formatUnits,
+ type SolanaConfig,
+} from "@/lib/solana/config";
+import type { SolanaHistory as SolanaHistoryResponse } from "@/lib/solana/history";
+
+function Candles({ candles }: { candles: SolanaHistoryResponse["candles"] }) {
+ const box = useRef(null);
+ const { resolvedTheme } = useTheme();
+ const valid = candles.every((c) =>
+ [c.open, c.high, c.low, c.close].every(
+ (p) =>
+ Number.isFinite(Number(p)) &&
+ Number(p) > 0 &&
+ Number(p) < 90_071_992_547_409,
+ ),
+ );
+ useEffect(() => {
+ if (!box.current || !valid || !candles.length) return;
+ const style = getComputedStyle(box.current);
+ const chart = createChart(box.current, {
+ width: box.current.clientWidth,
+ height: 280,
+ layout: {
+ background: { type: ColorType.Solid, color: "transparent" },
+ textColor: style.color,
+ fontFamily: style.fontFamily,
+ attributionLogo: true,
+ },
+ grid: {
+ vertLines: { visible: false },
+ horzLines: { color: resolvedTheme === "dark" ? "#202020" : "#e7e7e4" },
+ },
+ rightPriceScale: { borderVisible: false },
+ timeScale: {
+ borderVisible: false,
+ timeVisible: true,
+ secondsVisible: false,
+ },
+ });
+ const series = chart.addSeries(CandlestickSeries, {
+ upColor: "#12a474",
+ downColor: "#e04458",
+ borderVisible: false,
+ wickUpColor: "#12a474",
+ wickDownColor: "#e04458",
+ priceFormat: { type: "price", precision: 12, minMove: 0.000000000001 },
+ });
+ series.setData(
+ candles.map((c) => ({
+ time: c.t as UTCTimestamp,
+ open: Number(c.open),
+ high: Number(c.high),
+ low: Number(c.low),
+ close: Number(c.close),
+ })),
+ );
+ chart.timeScale().fitContent();
+ const resize = new ResizeObserver(() => {
+ if (box.current) chart.applyOptions({ width: box.current.clientWidth });
+ });
+ resize.observe(box.current);
+ return () => {
+ resize.disconnect();
+ chart.remove();
+ };
+ }, [candles, valid, resolvedTheme]);
+ if (!valid)
+ return (
+
+ Prices exceed this chart’s display range. Exact trade amounts remain
+ available below.
+
+ );
+ return (
+
+ );
+}
+
+export default function SolanaHistory({
+ config,
+ pool,
+ sequence,
+}: {
+ config: SolanaConfig;
+ pool: string;
+ sequence: string;
+}) {
+ const key = `${config.cluster}:${config.programId}:${pool}`;
+ const [record, setRecord] = useState<{
+ key: string;
+ value: SolanaHistoryResponse;
+ } | null>(null);
+ const [notice, setNotice] = useState<{ key: string; error: string } | null>(
+ null,
+ );
+ const result = record?.key === key ? record.value : null;
+ const error = notice?.key === key ? notice.error : "";
+ useEffect(() => {
+ const abort = new AbortController();
+ let reading = false;
+ async function load() {
+ if (reading || document.hidden || abort.signal.aborted) return;
+ reading = true;
+ try {
+ const response = await fetch(
+ `/api/solana/history/${encodeURIComponent(pool)}`,
+ {
+ signal: AbortSignal.any([
+ abort.signal,
+ AbortSignal.timeout(20_000),
+ ]),
+ cache: "no-store",
+ },
+ );
+ if (!response.ok)
+ throw new Error(
+ "Trade history is unavailable. Pool balances and trading remain independent of this chart.",
+ );
+ const value = (await response.json()) as SolanaHistoryResponse;
+ if (value.pool !== pool || !/^\d+$/.test(value.throughSequence))
+ throw new Error("History response did not match this pool.");
+ if (!abort.signal.aborted) {
+ // Confirmed state can reorganize. Do not retain an orphaned higher
+ // sequence merely because the newest RPC snapshot has fewer trades.
+ setRecord({ key, value });
+ setNotice(null);
+ }
+ } catch (e: unknown) {
+ if (!abort.signal.aborted)
+ setNotice({
+ key,
+ error: e instanceof Error ? e.message : "History unavailable.",
+ });
+ } finally {
+ reading = false;
+ }
+ }
+ void load();
+ // The API caches for 15 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 () => {
+ abort.abort();
+ clearInterval(timer);
+ document.removeEventListener("visibilitychange", load);
+ };
+ }, [key, pool, sequence]);
+ return (
+
+ Recent trading
+
+ SOL per token · 5-minute candles · Confirmed program events
+
+ {error ? (
+
+ {error}
+
+ ) : !result ? (
+
+ Reading recent transactions…
+
+ ) : (
+ <>
+ {result.candles.length ? (
+
+ ) : (
+
+ 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."
+ : ""}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/app/src/components/solana/SolanaWorkspace.tsx b/app/src/components/solana/SolanaWorkspace.tsx
new file mode 100644
index 00000000..324f502d
--- /dev/null
+++ b/app/src/components/solana/SolanaWorkspace.tsx
@@ -0,0 +1,548 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Connection, PublicKey } from "@solana/web3.js";
+import {
+ ArrowLeft,
+ ArrowUpRight,
+ Check,
+ LoaderCircle,
+ RefreshCw,
+ ShieldAlert,
+ Wallet,
+} from "lucide-react";
+import {
+ decodePoolAccount,
+ encodeBase58,
+ fetchPoolSnapshot,
+ verifyImmutableProgram,
+ type PoolSnapshot,
+} from "@openlaunch/solana-sdk";
+import { btn, input, label as labelClass } from "@/components/ui";
+import {
+ explorerUrl,
+ formatUnits,
+ publicKey,
+ type SolanaConfig,
+} from "@/lib/solana/config";
+import { useSolanaWallet } from "@/lib/solana/wallet";
+import { useSolanaAction } from "@/lib/solana/useSolanaAction";
+import LaunchPanel from "./LaunchPanel";
+import PoolPanel from "./PoolPanel";
+
+export type ActionController = ReturnType;
+export type SolanaPanelProps = {
+ connection: Connection;
+ config: SolanaConfig;
+ address: string | null;
+ actions: ActionController;
+ onPool: (address: string) => void;
+};
+const short = (key: string) => `${key.slice(0, 5)}…${key.slice(-5)}`;
+
+export default function SolanaWorkspace({
+ config,
+ initialPool,
+}: {
+ config: SolanaConfig;
+ initialPool?: string;
+}) {
+ const [connection] = useState(
+ () =>
+ new Connection(
+ typeof window === "undefined"
+ ? "http://localhost/api/solana/rpc"
+ : new URL(config.rpcPath, window.location.origin).href,
+ {
+ commitment: "confirmed",
+ disableRetryOnRateLimit: true,
+ fetch: (url, init) =>
+ fetch(url, { ...init, signal: AbortSignal.timeout(20_000) }),
+ },
+ ),
+ );
+ const programId = useMemo(
+ () => new PublicKey(config.programId),
+ [config.programId],
+ );
+ const wallet = useSolanaWallet(config.cluster);
+ const [lookup, setLookup] = useState(initialPool ?? "");
+ const [poolAddress, setPoolAddress] = useState(initialPool ?? "");
+ const [snapshot, setSnapshot] = useState(null);
+ const [list, setList] = useState<
+ { key: string; name: string; symbol: string }[]
+ >([]);
+ const [listTotal, setListTotal] = useState(0);
+ const [failure, setFailure] = useState("");
+ const [verified, setVerified] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [refresh, setRefresh] = useState(0);
+ const [view, setView] = useState<"explore" | "launch">("explore");
+ const reload = useCallback(() => setRefresh((n) => n + 1), []);
+ const settle = useCallback((pool: string | null) => {
+ if (pool) {
+ setPoolAddress(pool);
+ setLookup(pool);
+ setView("explore");
+ }
+ setRefresh((n) => n + 1);
+ }, []);
+ const actions = useSolanaAction(
+ config,
+ connection,
+ wallet.session,
+ wallet.sign,
+ settle,
+ `${poolAddress}:${view}:${refresh}`,
+ );
+ const address = wallet.session?.account.address ?? null;
+ const openPool = useCallback((key: string) => {
+ setPoolAddress(key);
+ setLookup(key);
+ setView("explore");
+ }, []);
+ useEffect(() => {
+ let alive = true;
+ const load = async () => {
+ setLoading(true);
+ setFailure("");
+ setSnapshot(null);
+ setVerified(false);
+ try {
+ await verifyImmutableProgram(connection, programId);
+ if (!alive) return;
+ setVerified(true);
+ if (poolAddress) {
+ const result = await fetchPoolSnapshot(
+ connection,
+ programId,
+ new PublicKey(poolAddress),
+ );
+ if (alive) setSnapshot(result);
+ } else {
+ const digest = new Uint8Array(
+ await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode("account:Pool"),
+ ),
+ );
+ 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);
+ }
+ }
+ } catch (e) {
+ if (alive)
+ setFailure(
+ e instanceof Error
+ ? e.message
+ : "Could not read Solana state. Try again.",
+ );
+ } finally {
+ if (alive) setLoading(false);
+ }
+ };
+ void load();
+ return () => {
+ alive = false;
+ };
+ }, [connection, programId, poolAddress, refresh]);
+ const props: SolanaPanelProps = {
+ connection,
+ config,
+ address,
+ actions,
+ onPool: openPool,
+ };
+ const locked = actions.busy || Boolean(actions.pending) || !actions.restored;
+ return (
+
+
+
+ Launchpad
+
+
+
+
+ {address ? (
+ <>
+
+
+ {short(address)}
+ {wallet.session?.wallet.name}
+
+
+ void wallet
+ .disconnect()
+ .catch(() => actions.setError("Wallet disconnected locally."))
+ }
+ >
+ Disconnect
+
+ >
+ ) : (
+ <>
+
+ Connect a Solana wallet to launch or trade.
+
+
+ {wallet.wallets.map((w, i) => (
+
+ void wallet
+ .connect(w)
+ .catch((e: unknown) =>
+ actions.setError(
+ e instanceof Error
+ ? e.message
+ : "Wallet connection failed.",
+ ),
+ )
+ }
+ >
+ {w.name}
+
+ ))}
+ {wallet.wallets.length === 0 && (
+
+ No compatible wallet detected. Open in a Wallet
+ Standard-enabled browser or wallet.
+
+ )}
+
+ >
+ )}
+
+
+ {actions.pending && (
+
+
+ {actions.pending.label}: waiting for finality
+
+
+ {actions.message ||
+ "Checking the signed transaction. Do not submit a replacement."}
+
+
+
+ View transaction
+
+
+
void actions.check()}
+ >
+ Check status
+
+ {actions.pending.pool && (
+
openPool(actions.pending!.pool!)}
+ >
+ Open pool
+
+ )}
+
+
+ This record stays in this browser across reloads. Do not clear site
+ storage while confirmation is unresolved.
+
+
+ )}
+ {!actions.pending && actions.message && (
+
+ {actions.message}
+
+ )}
+ {(failure || actions.error) && (
+
+
+
+
{failure || actions.error}
+
+ No new transaction is sent automatically. If you already signed,
+ check its status first.
+
+
+
+ )}
+
+ {actions.review ? (
+
+
+ Review {actions.review.label.toLowerCase()}
+
+
+ {[
+ ["Wallet", address ?? "Disconnected"],
+ ["Network", config.cluster],
+ ["Program", config.programId],
+ ...(actions.review.pool
+ ? [["Pool address", actions.review.pool]]
+ : []),
+ ...actions.review.lines,
+ [
+ "Network fee (estimated)",
+ `${formatUnits(BigInt(actions.review.networkFee), 9)} SOL`,
+ ],
+ ].map(([term, value]) => (
+
+
{term}
+
+ {value}
+
+
+ ))}
+
+
+ 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.
+
+
+ void actions.confirm()}
+ >
+ {actions.busy ? (
+
+ ) : null}
+ Confirm in wallet
+
+
+ Back
+
+
+
+ ) : (
+ <>
+
+
+ setView("explore")}
+ >
+ Pools
+
+ setView("launch")}
+ disabled={!verified}
+ >
+ Launch a token
+
+
+
+
+ Refresh
+
+
+ {view === "launch" ? (
+
+ ) : (
+ <>
+ {
+ event.preventDefault();
+ if (publicKey(lookup)) openPool(lookup);
+ else
+ actions.setError(
+ "Enter the complete, case-sensitive pool address.",
+ );
+ }}
+ >
+
+ Find a pool
+ setLookup(e.target.value)}
+ placeholder="Solana pool address"
+ autoComplete="off"
+ spellCheck={false}
+ />
+
+
+ Open
+
+
+ {poolAddress && (
+ {
+ setPoolAddress("");
+ setLookup("");
+ }}
+ >
+ Back to pool list
+
+ )}
+ {loading ? (
+
+ Reading program and pool accounts…
+
+ ) : snapshot ? (
+
+ ) : !poolAddress && !failure ? (
+
+
+ Active pools
+
+ {list.length} shown
+ {listTotal > list.length ? ` of ${listTotal}` : ""}
+
+
+ {list.length ? (
+
+ {list.map((pool) => (
+
+
+
+
+ {pool.name}
+
+
+ {pool.symbol} · SOL pair
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ No active pools on this program yet. Prepare and activate
+ a launch to start a market.
+
+ )}
+
+ ) : null}
+ >
+ )}
+ >
+ )}
+
+ No Openlaunch fee. Trading fees, if selected, go only to the pool’s
+ fixed beneficiaries. Locked liquidity does not guarantee profit or a
+ sale price. The virtual offset is not SOL in custody. This review
+ workspace has no aggregator listing, price-oracle, or cross-chain bridge
+ dependency.
+
+
+ );
+}
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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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;
}
From d7c6f357c355c896260f12a21d6ab3fdd9547a18 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Wed, 14 Oct 2026 16:18:09 +0530
Subject: [PATCH 10/11] fix(solana): migrate client and preserve guarded build
coverage
Replace the local SDK package link with source aliases and migrate to web3.js 3 with strict Kit signature verification, exact RPC integers, and compatible transaction recovery. Keep SDK/model and Rust/SBF checks in the existing app CI gate without changing main-owned workflows. Document the remaining pre-existing Tailwind bundled-dependency guard conflict without hiding lock metadata.
---
.github/workflows/ci.yml | 36 -
app/Dockerfile | 1 -
app/next.config.ts | 1 -
app/package-lock.json | 1942 +++++++++++------
app/package.json | 10 +-
app/packages/solana-sdk/README.md | 16 +-
app/packages/solana-sdk/package.json | 2 +-
.../solana-sdk/scripts/print-abi-fixture.ts | 18 +-
.../solana-sdk/scripts/verify-manifest.ts | 11 +-
app/packages/solana-sdk/src/accounts.ts | 76 +-
app/packages/solana-sdk/src/encoding.ts | 8 +-
app/packages/solana-sdk/src/instructions.ts | 34 +-
app/packages/solana-sdk/src/transactions.ts | 66 +-
app/packages/solana-sdk/src/verification.ts | 4 +-
.../solana-sdk/tests/accounts.test.ts | 67 +-
.../solana-sdk/tests/instructions.test.ts | 71 +-
.../solana-sdk/tests/rpc-compat.test.ts | 123 ++
.../solana-sdk/tests/transactions.test.ts | 119 +-
.../solana-sdk/tests/verification.test.ts | 20 +-
app/scripts/solana-checks.mjs | 87 +
app/src/app/api/solana/rpc/route.ts | 32 +-
app/src/components/solana/LaunchPanel.tsx | 6 +-
app/src/components/solana/PoolPanel.tsx | 43 +-
app/src/components/solana/SolanaWorkspace.tsx | 2 +-
app/src/lib/solana/bounded-json.test.ts | 37 +-
app/src/lib/solana/bounded-json.ts | 13 +-
app/src/lib/solana/ci-checks.test.ts | 136 ++
app/src/lib/solana/client-migration.test.ts | 55 +
app/src/lib/solana/config.test.ts | 14 +
app/src/lib/solana/config.ts | 16 +-
app/src/lib/solana/history.test.ts | 60 +-
app/src/lib/solana/history.ts | 28 +-
app/src/lib/solana/rpc-policy.test.ts | 213 +-
app/src/lib/solana/rpc-policy.ts | 65 +-
app/src/lib/solana/sdk-packaging.test.ts | 44 +
app/src/lib/solana/useSolanaAction.ts | 23 +-
app/tsconfig.json | 6 +-
app/tsconfig.test.json | 2 +
docs/SOLANA_IMMUTABLE_POOL_PLAN.md | 2 +-
docs/SOLANA_RELEASE.md | 19 +-
solana/BUILD.md | 26 +
41 files changed, 2408 insertions(+), 1146 deletions(-)
create mode 100644 app/packages/solana-sdk/tests/rpc-compat.test.ts
create mode 100644 app/scripts/solana-checks.mjs
create mode 100644 app/src/lib/solana/ci-checks.test.ts
create mode 100644 app/src/lib/solana/client-migration.test.ts
create mode 100644 app/src/lib/solana/sdk-packaging.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 91810074..06896e19 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,11 +39,6 @@ jobs:
- run: npm run lint
- run: npx tsc --noEmit -p .
- run: npm test
- - name: Solana SDK and independent economic model
- run: |
- 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
- name: production build
env:
NEXT_PUBLIC_SITE_URL: https://openlaunch.lol
@@ -53,37 +48,6 @@ jobs:
NEXT_PUBLIC_LAUNCH_LOCKER_ROBINHOOD: "0xcd1680D26922fcd9CabFbb8a56bA40C333fD842a"
run: npm run build
- solana:
- name: Solana · host tests, SBFv3 build, offline VM
- runs-on: ubuntu-latest
- timeout-minutes: 35
- defaults:
- run:
- 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
- components: rustfmt, clippy
- - uses: Swatinem/rust-cache@v2
- with:
- workspaces: solana
- - run: cargo fmt --all --check
- - run: cargo test -p launch-pool --locked
- - name: Install pinned build-only SBF tool
- run: cargo install cargo-build-sbf --version 4.2.0 --locked --root .tools
- - name: Build current SBPF format, reject stack diagnostics
- run: bash scripts/check-sbf.sh
- - name: Execute compiled program offline
- run: cargo test --workspace --features svm-tests --locked
- - name: Rust lint
- run: cargo clippy --workspace --all-targets --features svm-tests --locked -- -D warnings
- # No RPC credentials, wallet keys, deployments, or authority operations in CI.
-
contracts:
name: contracts · build + unit tests
runs-on: ubuntu-latest
diff --git a/app/Dockerfile b/app/Dockerfile
index 6d13a989..44a85342 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -5,7 +5,6 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
-COPY packages/solana-sdk ./packages/solana-sdk
RUN npm ci --no-audit --no-fund
COPY . .
diff --git a/app/next.config.ts b/app/next.config.ts
index e58126e3..1e8433ab 100644
--- a/app/next.config.ts
+++ b/app/next.config.ts
@@ -4,7 +4,6 @@ import { SECURITY_HEADERS } from "./src/lib/security-headers";
const nextConfig: NextConfig = {
// Fly: the Dockerfile copies .next/standalone (server.js + traced node_modules).
output: "standalone",
- transpilePackages: ["@openlaunch/solana-sdk"],
// Keep the Postgres driver external (not bundled) so it is traced into
// .next/standalone/node_modules and scripts/migrate.mjs can import it inside
// the image (release_command runs there, outside the Next server).
diff --git a/app/package-lock.json b/app/package-lock.json
index 3895f6b8..355a18e7 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -11,14 +11,16 @@
"@aws-sdk/client-s3": "^3.1127.0",
"@base-ui/react": "^1.8.0",
"@coinbase/wallet-sdk": "^4.3.7",
+ "@noble/hashes": "1.8.0",
"@number-flow/react": "^0.6.2",
- "@openlaunch/solana-sdk": "file:./packages/solana-sdk",
+ "@solana/keys": "8.5.0",
"@solana/wallet-standard-features": "1.5.0",
- "@solana/web3.js": "1.99.0",
+ "@solana/web3.js": "3.0.2",
"@tanstack/react-query": "^5.91.3",
"@wallet-standard/app": "1.1.1",
"@wallet-standard/base": "1.1.1",
"@wallet-standard/features": "1.1.1",
+ "buffer": "6.0.3",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"lightweight-charts": "^5.2.1",
@@ -1564,9 +1566,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1583,9 +1582,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1602,9 +1598,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1621,9 +1614,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1640,9 +1630,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1659,9 +1646,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1678,9 +1662,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1697,9 +1678,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1716,9 +1694,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1741,9 +1716,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1766,9 +1738,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1791,9 +1760,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1816,9 +1782,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1841,9 +1804,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1866,9 +1826,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1891,9 +1848,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2161,9 +2115,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2180,9 +2131,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2199,9 +2147,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2218,9 +2163,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2363,10 +2305,6 @@
"react-dom": "^18 || ^19"
}
},
- "node_modules/@openlaunch/solana-sdk": {
- "resolved": "packages/solana-sdk",
- "link": true
- },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2491,31 +2429,96 @@
"node": ">=18.0.0"
}
},
- "node_modules/@solana/buffer-layout": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz",
- "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==",
+ "node_modules/@solana-program/address-lookup-table": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@solana-program/address-lookup-table/-/address-lookup-table-0.15.0.tgz",
+ "integrity": "sha512-O8pkQCmuT1Uo+KvAU7zg8fKWUIA0PoJzOtY2O9UVuN7EzDXCfOFeG8YzvnsMAfsgSXMWSRFhMt6pFxUBtny3qg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@solana/kit": "^8.3.0"
+ }
+ },
+ "node_modules/@solana-program/compute-budget": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/@solana-program/compute-budget/-/compute-budget-0.19.0.tgz",
+ "integrity": "sha512-YV5T0MnTM//avw46UBvWswZw3KZx+HgqohPZkH/OonEHRX4+MSO9wV/8XePEDDyu/geHtdmdEXY/HlgRGJKa8A==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@solana/kit": "^8.3.0"
+ }
+ },
+ "node_modules/@solana-program/loader-v3": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@solana-program/loader-v3/-/loader-v3-0.7.0.tgz",
+ "integrity": "sha512-99XGVhGstmJX0eeZllqWhOuxYdhdCs/GZhVJW+by3g5vVz8qQ7qxGOweKdXXa4kQZvAWlUu3owKnVVIV+V8xdQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@solana/kit": "^8.3.0"
+ }
+ },
+ "node_modules/@solana-program/stake": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@solana-program/stake/-/stake-0.9.0.tgz",
+ "integrity": "sha512-mLRn8Hjo7ntHepV2I9fwRNLU1tIJuDc456cbOJSKjEgk9yPKVNHXkwQ6cnl7jDROde4CRjLfEpVLB5OhFqRZhQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=24.0.0"
+ },
+ "peerDependencies": {
+ "@solana/kit": "^8.0.0"
+ }
+ },
+ "node_modules/@solana-program/system": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.15.0.tgz",
+ "integrity": "sha512-xE08wZCyNRhOaO/XNRd9A3kSLfUrTe/BnmAHhql8uqc2eriNy2ZmnvaGRrs0m+kOSihvUbANzHljmJsTzGL5kg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@solana/kit": "^8.3.0"
+ }
+ },
+ "node_modules/@solana/accounts": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-8.5.0.tgz",
+ "integrity": "sha512-vwwXYCobg1oG4kFeUEsGiNORjPujI2ff3yKLp6lCa7QBIlKaD7kZ2QRI3PZFYcNrMJ6HYNwNeZXR7ixg02pWdA==",
"license": "MIT",
"dependencies": {
- "buffer": "~6.0.3"
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/rpc-spec": "8.5.0",
+ "@solana/rpc-types": "8.5.0"
},
"engines": {
- "node": ">=5.10"
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@solana/codecs-core": {
- "version": "5.5.1",
- "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz",
- "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==",
+ "node_modules/@solana/addresses": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-8.5.0.tgz",
+ "integrity": "sha512-LwTrDbDSJBng9y0vwiKqb/kSS9daspTGrSYoZPafeMWpAlpmutdD1KXPDHVTa4Xz00hcAfOJXYxRXX5VAoiOag==",
"license": "MIT",
"dependencies": {
- "@solana/errors": "5.5.1"
+ "@solana/assertions": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/nominal-types": "8.5.0"
},
"engines": {
"node": ">=20.18.0"
},
"peerDependencies": {
- "typescript": "^5.0.0"
+ "typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2523,20 +2526,19 @@
}
}
},
- "node_modules/@solana/codecs-numbers": {
- "version": "5.5.1",
- "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz",
- "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==",
+ "node_modules/@solana/assertions": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-8.5.0.tgz",
+ "integrity": "sha512-EZDY5rJIV8Mv9xYJ7P+jvE5pihG7pmKZ6pTqbunba/4uZSQN4Klw0FX54LDDklfDvhzmAdXN0nYAnjovTGAPEg==",
"license": "MIT",
"dependencies": {
- "@solana/codecs-core": "5.5.1",
- "@solana/errors": "5.5.1"
+ "@solana/errors": "8.5.0"
},
"engines": {
"node": ">=20.18.0"
},
"peerDependencies": {
- "typescript": "^5.0.0"
+ "typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2544,23 +2546,24 @@
}
}
},
- "node_modules/@solana/errors": {
- "version": "5.5.1",
- "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz",
- "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==",
+ "node_modules/@solana/codecs": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-8.5.0.tgz",
+ "integrity": "sha512-wALOoEuL7HAaahZnrhxCwYz10p23hku/LPNfXjlvwgjVYJL08PH3uyWDFO8e9zhcNLOaR13IREE9z8zPUhJjDQ==",
"license": "MIT",
"dependencies": {
- "chalk": "5.6.2",
- "commander": "14.0.2"
- },
- "bin": {
- "errors": "bin/cli.mjs"
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-data-structures": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/fixed-points": "8.5.0",
+ "@solana/options": "8.5.0"
},
"engines": {
"node": ">=20.18.0"
},
"peerDependencies": {
- "typescript": "^5.0.0"
+ "typescript": ">=5.4.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -2568,150 +2571,1096 @@
}
}
},
- "node_modules/@solana/wallet-standard-features": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.5.0.tgz",
- "integrity": "sha512-gxlLaEfPAqbhd3LNkK0Ks2pbVvDm1SDGqBK/ynDQy13Gu+qiURLpRCfSPjbtrw5bmqQsZ6rS2YJHL31bY/Koag==",
- "license": "Apache-2.0",
+ "node_modules/@solana/codecs-core": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-8.5.0.tgz",
+ "integrity": "sha512-ou38qNrf9XOzCWsu/I4sTzRmnrKqhblwMVvpXwXPKAAUZwkSAKQXonwgZFY68I5HK6nkfq1NLWfZFmTEIZYLiw==",
+ "license": "MIT",
"dependencies": {
- "@wallet-standard/base": "^1.1.0",
- "@wallet-standard/features": "^1.1.0"
+ "@solana/errors": "8.5.0"
},
"engines": {
- "node": ">=22"
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@solana/web3.js": {
- "version": "1.99.0",
- "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.99.0.tgz",
- "integrity": "sha512-QZYQ2T1z6xWisoyALPq25i/QZTsRlM02BABtAsfaQ1p8wX4SdTfxrKTRue/ZZqrhNVh5oL7T/DUFiTS9DRgxow==",
+ "node_modules/@solana/codecs-data-structures": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-8.5.0.tgz",
+ "integrity": "sha512-CnnNriyltkgxZAKgAJoVRsRnrtdW4KCX37BUoIsquCM+AgSvhSbxaZNz0iaHjGOX7o1Z4s6+j7nAUgad0N/w5A==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.29.7",
- "@noble/curves": "^1.9.7",
- "@noble/hashes": "^1.8.0",
- "@solana/buffer-layout": "^4.0.1",
- "@solana/codecs-numbers": "^5.5.1",
- "agentkeepalive": "^4.6.0",
- "bn.js": "^5.2.5",
- "borsh": "^0.7.0",
- "bs58": "^4.0.1",
- "buffer": "6.0.3",
- "fast-stable-stringify": "^1.0.0",
- "jayson": "^4.3.0",
- "node-fetch": "^2.7.0",
- "rpc-websockets": "^9.0.2",
- "superstruct": "^2.0.2"
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@swc/helpers": {
- "version": "0.5.23",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
- "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
- "license": "Apache-2.0",
+ "node_modules/@solana/codecs-numbers": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-8.5.0.tgz",
+ "integrity": "sha512-6Vtf52rHzjf98ykejn9bNGMaQ2k1f4UMIa+BGMbBy4mIRQ5ggdI5oCAb3sZPfJcel2JQHwDlsLHCYdpqC+n6Mg==",
+ "license": "MIT",
"dependencies": {
- "tslib": "^2.8.0"
+ "@solana/codecs-core": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@tailwindcss/node": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
- "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
- "dev": true,
+ "node_modules/@solana/codecs-strings": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-8.5.0.tgz",
+ "integrity": "sha512-9lCSCUVkbPLb1o9PLDsaOatdVgrUqqWMrAqJvZPyyuvrC5++wPuKRIGq+5iU3lICY+AwWxrzVyiwdu4xaZVCiw==",
"license": "MIT",
"dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "^5.24.1",
- "jiti": "^2.7.0",
- "lightningcss": "1.32.0",
- "magic-string": "^0.30.21",
- "source-map-js": "^1.2.1",
- "tailwindcss": "4.3.3"
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "fastestsmallesttextencoderdecoder": "^1.0.22",
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "fastestsmallesttextencoderdecoder": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@tailwindcss/oxide": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
- "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
- "dev": true,
+ "node_modules/@solana/errors": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-8.5.0.tgz",
+ "integrity": "sha512-y/XTiwAZsm2kHoEDJ3ac3NUq/0E4uCbOnBe2m5mmBXF3TGmF562tDKgN5pQ+thiRABvi7b2LqwTBCg4kAdtgRg==",
"license": "MIT",
+ "dependencies": {
+ "chalk": "5.6.2",
+ "commander": "15.0.0"
+ },
+ "bin": {
+ "errors": "bin/cli.mjs"
+ },
"engines": {
- "node": ">= 20"
+ "node": ">=20.18.0"
},
- "optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.3.3",
- "@tailwindcss/oxide-darwin-arm64": "4.3.3",
- "@tailwindcss/oxide-darwin-x64": "4.3.3",
- "@tailwindcss/oxide-freebsd-x64": "4.3.3",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
- "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
- "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
- "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
+ "node_modules/@solana/fast-stable-stringify": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-8.5.0.tgz",
+ "integrity": "sha512-CTIoOwJ0NScrB8KTzr/V4PJrNHrBAz1zvamM0opgDPQZGzLrBhca1e4eQcGmF6o7Dv3Ve0HdvDJx0B2uG6YxVA==",
"license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
"engines": {
- "node": ">= 20"
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
- "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
+ "node_modules/@solana/fixed-points": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/fixed-points/-/fixed-points-8.5.0.tgz",
+ "integrity": "sha512-hkB7dUAD/oxXeNOvdMXVqfKZAvOJ1o50gBwzh8yLP1ihdSxVgssb36Vg7806C5HzmIxmTKZclJJokG0cRUXVSw==",
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
+ "dependencies": {
+ "@solana/codecs-core": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
"engines": {
- "node": ">= 20"
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
- "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
+ "node_modules/@solana/functional": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-8.5.0.tgz",
+ "integrity": "sha512-+8pgF1rVW78FfT0d+ceADFLKpSh/8/o9Ldx5tEkI6paiv0QeClL1rlU5nFs5tjoFkf+92x8PpiLEEGkm0CifOQ==",
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
"engines": {
- "node": ">= 20"
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
- "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
+ "node_modules/@solana/instruction-plans": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-8.5.0.tgz",
+ "integrity": "sha512-cES8TzRGuJ5cdVn3hr6GskwwG68rLd8R0FqmzB9NeU/nzPnW/50nRrg36DYX1Nh+OwuU/ehg6zkNNu1Vo72lKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/promises": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/instructions": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-8.5.0.tgz",
+ "integrity": "sha512-LSMOQRicz8OalMM2DT60PtAaPjvkHPhIvmPEI8KpyWOuNRwABJBRAfn5ypcdSa69VVj4IdOY10tDWZzjN4dviA==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/codecs-core": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/keys": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-8.5.0.tgz",
+ "integrity": "sha512-mq6amguPYLklpAOC9A+boXQhpcHGjcn4QN40J0l1TvUkfWyFJ4HBbNb35fyWldqqDUV5ujOpaPZmmVvWJBK2Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/assertions": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/nominal-types": "8.5.0",
+ "@solana/promises": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/kit": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-8.5.0.tgz",
+ "integrity": "sha512-bMnXXADeyZFe3XyqzPhlQAIsmPYRiojaRO4JQxwGOmMwiQ5f13ZQodmsOqnt8W/n40P7r/+/pDqWuqG0TUv+yA==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/accounts": "8.5.0",
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/instruction-plans": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/offchain-messages": "8.5.0",
+ "@solana/plugin-core": "8.5.0",
+ "@solana/plugin-interfaces": "8.5.0",
+ "@solana/program-client-core": "8.5.0",
+ "@solana/programs": "8.5.0",
+ "@solana/promises": "8.5.0",
+ "@solana/rpc": "8.5.0",
+ "@solana/rpc-api": "8.5.0",
+ "@solana/rpc-parsed-types": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "@solana/rpc-subscriptions": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/signers": "8.5.0",
+ "@solana/subscribable": "8.5.0",
+ "@solana/sysvars": "8.5.0",
+ "@solana/transaction-confirmation": "8.5.0",
+ "@solana/transaction-introspection": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/nominal-types": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-8.5.0.tgz",
+ "integrity": "sha512-OfTZ+w/C59Zkp51RJ3dzn2GXUojf9p/PMmSrOpxrj+hci3bdh4e6XDGY+mvP8HGPH+m0nfKWTBCygEslfoX6bg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/offchain-messages": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-8.5.0.tgz",
+ "integrity": "sha512-C7qBBD1Gfbdv99uoQnuc8XZYwttTQJOYEG2v6z7q3mAL03PN7zdItAlBa+M2tpeRKjTJObRYnUUJDfiIyyGD/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-data-structures": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/nominal-types": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/options": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/options/-/options-8.5.0.tgz",
+ "integrity": "sha512-Ron9FrWDkwxlmuI6D+NmrZZRngC1MUncglgyN77ThAoxDE1gGMOB88mQvEILeeqONeTA1rDg1uBvbewQcILp+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-data-structures": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/plugin-core": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-8.5.0.tgz",
+ "integrity": "sha512-1zmh+UC47yxsZHleDpH4AEVkm9yXIATkWm8v33DJuXaqX3TQXMXuxP39cXAyObIQEpCG8DK8lyAeFTUrIFcDmA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/plugin-interfaces": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/plugin-interfaces/-/plugin-interfaces-8.5.0.tgz",
+ "integrity": "sha512-+s/+cCuzk23/a1ViTIugvXFYHN0txxmvqfuL4qx81NlmGawqEYws6xD+L02wNzJ5rbJeAaBhw2pUFStMfG4vYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/accounts": "8.5.0",
+ "@solana/addresses": "8.5.0",
+ "@solana/instruction-plans": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/rpc-spec": "8.5.0",
+ "@solana/rpc-subscriptions-spec": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/signers": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/program-client-core": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/program-client-core/-/program-client-core-8.5.0.tgz",
+ "integrity": "sha512-HJcnbuvQRcWOV3nTUjzgmQRIs0Of1973UU8uOvSEEW7rxycQwNxJ7fVgxQ5sYOCbT+7rlqqTkDeczvB7I4OwoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/accounts": "8.5.0",
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/instruction-plans": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/plugin-interfaces": "8.5.0",
+ "@solana/rpc-api": "8.5.0",
+ "@solana/signers": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/programs": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-8.5.0.tgz",
+ "integrity": "sha512-8PC2E/d5rwCRw1tFwQlpG65YDN56bYmPiBl99QqCP2RzhNdFJ/52xgFumL2G0uLfD3xsuFaWuOIGNxkyfXYm/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/promises": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-8.5.0.tgz",
+ "integrity": "sha512-qWJbU6RGuZVrtvsGZ+2F46jccaIZDX4czYRRhS3m38cCdE+wKMbqGvWND50xP7D9egOxYKHMqJAJbLQH4gspvQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-8.5.0.tgz",
+ "integrity": "sha512-7CQFNOMhXrY/LT0qdsi3h0chy4A/Ao93pyBLqFQqxzlub0st8dC5wl+MDXhkxaBos/DZOu1V4EleWv6qbLMlow==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/fast-stable-stringify": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/rpc-api": "8.5.0",
+ "@solana/rpc-spec": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "@solana/rpc-transformers": "8.5.0",
+ "@solana/rpc-transport-http": "8.5.0",
+ "@solana/rpc-types": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-8.5.0.tgz",
+ "integrity": "sha512-qlRZ2yC8WEWNg3VX8UEqlZOitKZaeE6KWbn3A/ybtJAYz+mvr3RMDhnQ89RDqIuxPuAFZNV21BRJv3yB9xR44Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/rpc-parsed-types": "8.5.0",
+ "@solana/rpc-spec": "8.5.0",
+ "@solana/rpc-transformers": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-parsed-types": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-8.5.0.tgz",
+ "integrity": "sha512-ktygZ019trgAyjr8y1GN1x4x6wJYw2Ola6UogAfJT5LoNs6kV3k84yCNAlGv34xRPqCVxlWo4nsIWJf8yWQQ2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-spec": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-8.5.0.tgz",
+ "integrity": "sha512-0oET5yc880zFGqBMJE2UB7sxW8sIs4hmsP4bmkR2y4ipm1aScC5U+Rd8GaIrupBOVOFj+RXIorC7kQycN/9nhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "@solana/subscribable": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-spec-types": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-8.5.0.tgz",
+ "integrity": "sha512-YUTMKWWlHCGBmA1wxhIilbxJlad5vEsvc8dDO9VWr7GVkQUlipEU79iGaKk4xQ6Oo1mZWxpGz5mAJUqGraTK4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-subscriptions": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-8.5.0.tgz",
+ "integrity": "sha512-8hdWSDKKtMK3kRrR8TExwFo/bWHxVTu4zJ/JkePTQgNtm95kX25/hy+9JHKRtSsNUfXXYM60sQVE18cGgMU7dA==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/fast-stable-stringify": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/promises": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "@solana/rpc-subscriptions-api": "8.5.0",
+ "@solana/rpc-subscriptions-channel-websocket": "8.5.0",
+ "@solana/rpc-subscriptions-spec": "8.5.0",
+ "@solana/rpc-transformers": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/subscribable": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-subscriptions-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-8.5.0.tgz",
+ "integrity": "sha512-PJaAyqZkpG8vuzdxmdWMwp1JS5pyZ6PuL8OMhx6nCt3phkseqIs1NmAf8pQc/RZjIV3hJMMVCwSDG1DE8DY64w==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/rpc-subscriptions-spec": "8.5.0",
+ "@solana/rpc-transformers": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-subscriptions-channel-websocket": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-8.5.0.tgz",
+ "integrity": "sha512-sRYYwBFg31z77zfKpJBcAO9MaUWa+bO1PlBcDiToa4luXGuR3dC8TSpi1Yip4T4NCONNzK3yhyeI6sHhGHc8Qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/rpc-subscriptions-spec": "8.5.0",
+ "@solana/subscribable": "8.5.0",
+ "ws": "^8.21.3"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-subscriptions-spec": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-8.5.0.tgz",
+ "integrity": "sha512-YdCPLHOcQWz8JhMaT/E+YC56nFDJ6D+XOTr6qRWHGe/1PIYGQDVvaFzI7+tF0b6tuv49GoMbpGdkiTX2v6KeUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/promises": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "@solana/subscribable": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-transformers": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-8.5.0.tgz",
+ "integrity": "sha512-JYq+EyfzJ1ksZubU9VHynQY1Z3k1tosdxG6KE4Cua/rFuXPosSn7o5h9FLLbSB5cTVcnj0lGNrBaVYW4AVK4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/nominal-types": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "@solana/rpc-types": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-transport-http": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-8.5.0.tgz",
+ "integrity": "sha512-xfIFZa+oxXLgA6BXaE2bXjiy2yZM1qO1URzzF0lmTekJM/okS8EZAQwEicWpYkvfdHJZ2x/oagD3AOSBBCWrsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/rpc-spec": "8.5.0",
+ "@solana/rpc-spec-types": "8.5.0",
+ "undici-types": "^8.11.2"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/rpc-types": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-8.5.0.tgz",
+ "integrity": "sha512-L050pZqQ8gaFABF26NAYoatyZZeJo52AKvQQ3i58LRtUc6wcrpCF+4x4U/EDER2YVAc6Qx1xWvGnAomGhI6vzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/fixed-points": "8.5.0",
+ "@solana/nominal-types": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/signers": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-8.5.0.tgz",
+ "integrity": "sha512-F9VvFsG9Av+Sbs4mQpuVlszb6msujxnqKQqa4TsRT24GOqKOFr4Xvs7HqgVJV/BPIDnz7u0omUhUILesHJDfkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/nominal-types": "8.5.0",
+ "@solana/offchain-messages": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/subscribable": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-8.5.0.tgz",
+ "integrity": "sha512-v1/hL/l4kubwYVuaMNmtQcmTgDuKG6e9Hjv9fCxkYCdDIUERgmm9DX/So1386+6rkQBVRC7wTSH86Jq+RIqNGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/errors": "8.5.0",
+ "@solana/promises": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/sysvars": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-8.5.0.tgz",
+ "integrity": "sha512-vidKFEOAujEVizDUHFCmmIpHsS3Zu/fXJKcDUtbUn+bwD9Z1fRiper8rZW3Qdtgu1GmrH3O8MoyL2Nrp7k6QNw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/accounts": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-data-structures": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/rpc-types": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/transaction-confirmation": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-8.5.0.tgz",
+ "integrity": "sha512-dUVHhyaJ9tt535ZJfgPAdKo6zycCAn1mqKKP/FYvCz/4FiAKnyvqj8rxd66yvvuJVhkpjbcPDDagm/P8JEYUug==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/promises": "8.5.0",
+ "@solana/rpc": "8.5.0",
+ "@solana/rpc-subscriptions": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/transaction-introspection": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/transaction-introspection/-/transaction-introspection-8.5.0.tgz",
+ "integrity": "sha512-VPYVLcpieu5YwwC1wmh3zZ/KHWmqJWPS7iMPVpf90L79/1Dvkg+YYQHnVeN4VQdb7VFnfpTqUnSQpfERDk0Sag==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/transaction-messages": "8.5.0",
+ "@solana/transactions": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/transaction-messages": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-8.5.0.tgz",
+ "integrity": "sha512-8SHxirU5K0kFmOwwD6XTGI5LWP/bLvLBDbemT4xDOaL+6xkLzlTMtzlgKb/Td5UtqyYOoew0FBVLJzJx/QApOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-data-structures": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/nominal-types": "8.5.0",
+ "@solana/rpc-types": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/transactions": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-8.5.0.tgz",
+ "integrity": "sha512-IGRWI38rq5b94gy93wn/gVSQBuwNPSUv3GnWkd6R6Tc7jLbfRlYbYHJNHH5o28Fmlr+nlEn4bgu3BiqMWxuLmg==",
+ "license": "MIT",
+ "dependencies": {
+ "@solana/addresses": "8.5.0",
+ "@solana/codecs-core": "8.5.0",
+ "@solana/codecs-data-structures": "8.5.0",
+ "@solana/codecs-numbers": "8.5.0",
+ "@solana/codecs-strings": "8.5.0",
+ "@solana/errors": "8.5.0",
+ "@solana/functional": "8.5.0",
+ "@solana/instructions": "8.5.0",
+ "@solana/keys": "8.5.0",
+ "@solana/nominal-types": "8.5.0",
+ "@solana/rpc-types": "8.5.0",
+ "@solana/transaction-messages": "8.5.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@solana/wallet-standard-features": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.5.0.tgz",
+ "integrity": "sha512-gxlLaEfPAqbhd3LNkK0Ks2pbVvDm1SDGqBK/ynDQy13Gu+qiURLpRCfSPjbtrw5bmqQsZ6rS2YJHL31bY/Koag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@wallet-standard/base": "^1.1.0",
+ "@wallet-standard/features": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@solana/web3.js": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-3.0.2.tgz",
+ "integrity": "sha512-xgA48BPOknXdwcXwOJLcjqQmJPQbBhMmE+OcHOrS25+lsbDePEkLyGwJbzB5V7UIm2GjkXLAPE6PDKj0YrLOOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^1.4.2",
+ "@noble/hashes": "^1.4.0",
+ "@solana-program/address-lookup-table": "^0.15.0",
+ "@solana-program/compute-budget": "^0.19.0",
+ "@solana-program/loader-v3": "^0.7.0",
+ "@solana-program/stake": "0.9.0",
+ "@solana-program/system": "^0.15.0",
+ "@solana/assertions": "^8.4.0",
+ "@solana/fast-stable-stringify": "^8.4.0",
+ "@solana/kit": "^8.4.0",
+ "@solana/rpc-spec-types": "^8.4.0",
+ "@solana/signers": "^8.4.0"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2746,9 +3695,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2766,9 +3712,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2786,9 +3729,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2806,9 +3746,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2837,17 +3774,83 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.11.1",
- "@emnapi/runtime": "^1.11.1",
- "@emnapi/wasi-threads": "^1.2.2",
- "@napi-rs/wasm-runtime": "^1.1.4",
- "@tybys/wasm-util": "^0.10.2",
- "tslib": "^2.8.1"
- },
- "engines": {
- "node": ">=14.0.0"
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -2933,15 +3936,6 @@
"tslib": "^2.4.0"
}
},
- "node_modules/@types/connect": {
- "version": "3.4.38",
- "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
- "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
@@ -2974,11 +3968,19 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/node/node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -2999,21 +4001,6 @@
"@types/react": "^19.2.0"
}
},
- "node_modules/@types/uuid": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
- "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
- "license": "MIT"
- },
- "node_modules/@types/ws": {
- "version": "7.4.7",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
- "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
@@ -3415,9 +4402,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3432,9 +4416,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3449,9 +4430,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3466,9 +4444,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3483,9 +4458,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3500,9 +4472,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3517,9 +4486,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3534,9 +4500,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3551,9 +4514,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3568,9 +4528,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3719,18 +4676,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/agentkeepalive": {
- "version": "4.6.0",
- "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
- "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
- "license": "MIT",
- "dependencies": {
- "humanize-ms": "^1.2.1"
- },
- "engines": {
- "node": ">= 8.0.0"
- }
- },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3984,15 +4929,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/base-x": {
- "version": "3.0.11",
- "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
- "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==",
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "^5.0.1"
- }
- },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -4025,23 +4961,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/bn.js": {
- "version": "5.2.5",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz",
- "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==",
- "license": "MIT"
- },
- "node_modules/borsh": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz",
- "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==",
- "license": "Apache-2.0",
- "dependencies": {
- "bn.js": "^5.2.0",
- "bs58": "^4.0.0",
- "text-encoding-utf-8": "^1.0.2"
- }
- },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@@ -4106,15 +5025,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/bs58": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
- "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
- "license": "MIT",
- "dependencies": {
- "base-x": "^3.0.2"
- }
- },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -4139,20 +5049,6 @@
"ieee754": "^1.2.1"
}
},
- "node_modules/bufferutil": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
- "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "node-gyp-build": "^4.3.0"
- },
- "engines": {
- "node": ">=6.14.2"
- }
- },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -4281,12 +5177,12 @@
"license": "MIT"
},
"node_modules/commander": {
- "version": "14.0.2",
- "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
- "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
- "node": ">=20"
+ "node": ">=22.12.0"
}
},
"node_modules/concat-map": {
@@ -4488,18 +5384,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/delay": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
- "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4764,21 +5648,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/es6-promise": {
- "version": "4.2.8",
- "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
- "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
- "license": "MIT"
- },
- "node_modules/es6-promisify": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
- "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==",
- "license": "MIT",
- "dependencies": {
- "es6-promise": "^4.0.3"
- }
- },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -5304,14 +6173,6 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
- "node_modules/eyes": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
- "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==",
- "engines": {
- "node": "> 0.1.90"
- }
- },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
@@ -5369,12 +6230,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-stable-stringify": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz",
- "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==",
- "license": "MIT"
- },
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
@@ -5811,15 +6666,6 @@
"hermes-estree": "0.25.1"
}
},
- "node_modules/humanize-ms": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
- "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.0.0"
- }
- },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6337,15 +7183,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/isomorphic-ws": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
- "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==",
- "license": "MIT",
- "peerDependencies": {
- "ws": "*"
- }
- },
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
@@ -6379,80 +7216,6 @@
"node": ">= 0.4"
}
},
- "node_modules/jayson": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz",
- "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==",
- "license": "MIT",
- "dependencies": {
- "@types/connect": "^3.4.33",
- "@types/node": "^12.12.54",
- "@types/ws": "^7.4.4",
- "commander": "^2.20.3",
- "delay": "^5.0.0",
- "es6-promisify": "^5.0.0",
- "eyes": "^0.1.8",
- "isomorphic-ws": "^4.0.1",
- "json-stringify-safe": "^5.0.1",
- "stream-json": "^1.9.1",
- "uuid": "^8.3.2",
- "ws": "^7.5.10"
- },
- "bin": {
- "jayson": "bin/jayson.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/jayson/node_modules/@types/node": {
- "version": "12.20.55",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
- "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
- "license": "MIT"
- },
- "node_modules/jayson/node_modules/commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "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",
- "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
- "license": "MIT",
- "engines": {
- "node": ">=8.3.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": "^5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -6520,12 +7283,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json-stringify-safe": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "license": "ISC"
- },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -6742,9 +7499,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6766,9 +7520,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6790,9 +7541,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6814,9 +7562,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7064,6 +7809,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -7217,38 +7963,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/node-fetch": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
- "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
- "license": "MIT",
- "dependencies": {
- "whatwg-url": "^5.0.0"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
- "node_modules/node-gyp-build": {
- "version": "4.8.4",
- "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
- "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
- "license": "MIT",
- "optional": true,
- "bin": {
- "node-gyp-build": "bin.js",
- "node-gyp-build-optional": "optional.js",
- "node-gyp-build-test": "build-test.js"
- }
- },
"node_modules/node-releases": {
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@@ -7841,51 +8555,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/rpc-websockets": {
- "version": "9.3.9",
- "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz",
- "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==",
- "license": "LGPL-3.0-only",
- "dependencies": {
- "@swc/helpers": "^0.5.11",
- "@types/uuid": "^10.0.0",
- "@types/ws": "^8.2.2",
- "buffer": "^6.0.3",
- "eventemitter3": "^5.0.1",
- "uuid": "^14.0.0",
- "ws": "^8.5.0"
- },
- "funding": {
- "type": "paypal",
- "url": "https://paypal.me/kozjak"
- },
- "optionalDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": "^6.0.0"
- }
- },
- "node_modules/rpc-websockets/node_modules/@types/ws": {
- "version": "8.18.2",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
- "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/rpc-websockets/node_modules/uuid": {
- "version": "14.0.3",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz",
- "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist-node/bin/uuid"
- }
- },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7930,26 +8599,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8246,21 +8895,6 @@
"node": ">= 0.4"
}
},
- "node_modules/stream-chain": {
- "version": "2.2.5",
- "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
- "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/stream-json": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
- "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "stream-chain": "^2.2.5"
- }
- },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -8421,15 +9055,6 @@
}
}
},
- "node_modules/superstruct": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz",
- "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -8487,11 +9112,6 @@
"url": "https://opencollective.com/webpack"
}
},
- "node_modules/text-encoding-utf-8": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
- "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg=="
- },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -8553,12 +9173,6 @@
"node": ">=8.0"
}
},
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT"
- },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -8772,9 +9386,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "version": "8.12.1",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.12.1.tgz",
+ "integrity": "sha512-nCiEcIx87oMw9u3Yzj2DuRBzv6p0e7akhy4HUPtpBiVWmAY3o6Phznst8QhmY1oLMttLgJ2tlAvsKuzCgBSoAw==",
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -8865,30 +9479,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/utf-8-validate": {
- "version": "6.0.6",
- "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz",
- "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "node-gyp-build": "^4.3.0"
- },
- "engines": {
- "node": ">=6.14.2"
- }
- },
- "node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
- "license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/viem": {
"version": "2.55.19",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.55.19.tgz",
@@ -9079,22 +9669,6 @@
}
}
},
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause"
- },
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "license": "MIT",
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9302,30 +9876,6 @@
"optional": true
}
}
- },
- "packages/solana-sdk": {
- "name": "@openlaunch/solana-sdk",
- "version": "0.1.0",
- "dependencies": {
- "@noble/hashes": "1.8.0",
- "@solana/web3.js": "1.99.0",
- "buffer": "6.0.3"
- },
- "devDependencies": {
- "@types/node": "^22",
- "tsx": "4.23.13",
- "typescript": "5.9.3"
- }
- },
- "packages/solana-sdk/node_modules/@types/node": {
- "version": "22.20.5",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
- "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
}
}
}
diff --git a/app/package.json b/app/package.json
index 09102804..7042714a 100644
--- a/app/package.json
+++ b/app/package.json
@@ -8,12 +8,15 @@
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
- "test": "tsx --conditions=react-server --tsconfig tsconfig.test.json --test \"src/**/*.test.ts\"",
+ "test": "tsx --conditions=react-server --tsconfig tsconfig.test.json --test \"src/**/*.test.ts\" && npm run test:solana && node scripts/solana-checks.mjs --ci",
+ "test:solana": "tsc --noEmit -p packages/solana-sdk/tsconfig.json && tsx --test \"packages/solana-sdk/tests/*.test.ts\" && node --experimental-strip-types --test \"../packages/solana-model/*.test.mjs\"",
+ "test:solana-runtime": "node scripts/solana-checks.mjs",
"migrate": "node scripts/migrate.mjs"
},
"dependencies": {
- "@openlaunch/solana-sdk": "file:./packages/solana-sdk",
- "@solana/web3.js": "1.99.0",
+ "@noble/hashes": "1.8.0",
+ "@solana/keys": "8.5.0",
+ "@solana/web3.js": "3.0.2",
"@solana/wallet-standard-features": "1.5.0",
"@wallet-standard/app": "1.1.1",
"@wallet-standard/base": "1.1.1",
@@ -23,6 +26,7 @@
"@coinbase/wallet-sdk": "^4.3.7",
"@number-flow/react": "^0.6.2",
"@tanstack/react-query": "^5.91.3",
+ "buffer": "6.0.3",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"lightweight-charts": "^5.2.1",
diff --git a/app/packages/solana-sdk/README.md b/app/packages/solana-sdk/README.md
index 17f6a108..9ccb5f06 100644
--- a/app/packages/solana-sdk/README.md
+++ b/app/packages/solana-sdk/README.md
@@ -2,11 +2,12 @@
Original TypeScript client for the unreleased immutable launch-pool program. This package is not an audited protocol and includes no production program ID. The compile/test fixture ID must never be used as a deployment configuration.
-Kept inside `app/packages` so the existing app-only Docker build context contains the local package. Import `@openlaunch/solana-sdk` from the app. Source exports require a TypeScript-aware bundler or `tsx`; Next supports this without a separate generated client. Public keys remain case-sensitive base58. Amounts are `bigint` in token atoms or SOL lamports, never JavaScript floating-point amounts.
+Kept inside `app/packages` so the existing app-only Docker build context contains the source. Import `@openlaunch/solana-sdk` from the app: TypeScript paths in both the app and test configurations resolve this name directly to the reviewed source, not an installed npm package or local `file:` link. The app owns the SDK's pinned registry dependencies; install them with `npm ci` from `app/`. Next and `tsx` compile the source without a generated client or `transpilePackages` entry. The private package manifest remains ESM metadata for direct SDK tooling. Public keys remain case-sensitive base58. Amounts are `bigint` in token atoms or SOL lamports, never JavaScript floating-point amounts.
## Dependencies
-- `@solana/web3.js` 1.99.0: pinned class-based transaction/account API shared with the app wallet integration. The v1 line is maintenance-only; a future major upgrade requires rerunning wallet message and serialization tests, not an unreviewed range bump.
+- `@solana/web3.js` 3.0.2: pinned class-based transaction/account API shared with the app wallet integration. The Kit-backed client removes the legacy native WebSocket install-script dependencies. Key derivation, instruction builders and signature validation are asynchronous; RPC amounts remain `bigint`.
+- `@solana/keys` 8.5.0: pinned strict signature verifier, including small-order public-key and signature-point rejection. web3.js 3.0.2 imports non-extractable verification keys, incompatible with Kit 8.5's security fix. Our shared validator imports extractable public keys and invokes the official strict verifier for every required signer. Serialization skips its redundant upstream verification only after our explicit check of a private byte snapshot; invalid or missing signatures still reject. Do not downgrade to bypass this requirement. See [Kit 8.5 release notes](https://github.com/anza-xyz/kit/releases/tag/v8.5.0).
- `@noble/hashes` 1.8.0: browser-compatible SHA-256 for Anchor discriminators and reviewed ELF verification. No private-key handling.
- `buffer` 6.0.3: explicit browser-compatible binary encoding instead of relying on a global Node `Buffer`.
@@ -33,7 +34,7 @@ The SDK does not depend on Anchor's JavaScript client, a DEX SDK, SPL Token meta
### Instruction builders
-All builders return a `TransactionInstruction`, never send or sign it.
+All builders return a `Promise`, never send or sign it. Await PDA helpers, checked account decoders and deployment-manifest validation too. `PoolSnapshot.slot` and prepared transaction expiry heights retain checked safe-number boundaries for the existing JSON history and v1 recovery journal; values outside that range reject rather than round.
- `buildPrepareLaunchInstruction(programId, { creator, payer?, nonce, virtualSol, feeBps, name, symbol, uri, contentHash, recipients })` binds immutable configuration. `contentHash` is exactly 32 bytes. `recipients` is an array of `{ address, weightBps }`, empty for 0% fees.
- `buildActivateLaunchInstruction(programId, creator, nonce)` activates only after the program's own upgrade authority is absent. The creator pays activation rent.
@@ -51,13 +52,13 @@ const prepared = await prepareTransaction(connection, walletPublicKey, instructi
await simulatePreparedTransaction(connection, prepared);
// Request wallet-standard signing of prepared.wireBytes, using the current account/cluster.
-const signed = validateSignedTransaction(prepared.messageBytes, walletSignedBytes, walletPublicKey);
+const signed = await validateSignedTransaction(prepared.messageBytes, walletSignedBytes, walletPublicKey);
// Persist signed.signature, prepared.blockhash and prepared.lastValidBlockHeight BEFORE submission.
const submitted = await submitSignedTransaction(connection, signed);
const result = await reconcileTransaction(connection, signed.signature, prepared.lastValidBlockHeight);
```
-The app must check the selected wallet account/cluster again after the wallet prompt. Returned transaction messages must be byte-identical to the reviewed message and all required signatures valid. The SDK intentionally supports legacy transactions only in this first integration. SDK preparation/simulation contains no user signer.
+The app must check the selected wallet account/cluster again after the wallet prompt and after asynchronous signature verification, immediately before durable persistence and submission. Returned transaction messages must be byte-identical to the reviewed message and all required signatures valid. The SDK intentionally supports legacy transactions only in this first integration. SDK preparation/simulation contains no user signer.
Submission errors return `unknown`, retaining the locally derived signature. Do not create a replacement transaction after a timeout. Reconciliation reads finalized block height and historical signature status. A processed/confirmed failure remains `pending` because it could exist only on a fork. Only finalized failure or expiry with no observed signature is terminal. Reliable archival RPC history is part of the operational assumptions; compare another trusted endpoint for unresolved anomalies. Never silently clear recovery state just because the UI was closed.
@@ -80,10 +81,11 @@ Retain its output with a reproducible source-to-ELF build attestation and a seco
From `app/`:
```text
-npx tsc --noEmit -p packages/solana-sdk/tsconfig.json
-npx tsx --test packages/solana-sdk/tests/*.test.ts
+npm run test:solana
```
+The normal `npm test` command also runs this gate: SDK typechecking, SDK tests and the independent economic-model tests. In CI it then runs the pinned Rust host, SBF build, offline runtime and Clippy checks through the existing app job; ordinary local tests explicitly report that runtime checks are skipped. Use `npm run test:solana-runtime` on a supported build host, or the Windows commands in [the build handoff](../../../solana/BUILD.md). The manifest verifier and ABI-fixture script keep their existing paths.
+
Tests cover golden values shared with Rust, independent binary-search oracle comparisons, u64/u128 limits, fee rounding, slippage, decoder/PDA substitution, donated surplus, immutable ProgramData linkage, wallet message mutation, partial/invalid signatures, ambiguous submission and packet measurements. `tests/abi-fixture.json` contains only deterministic public fixture identities and serialized ABI bytes, no private key or deployable configuration. `scripts/print-abi-fixture.ts` reproduces it for intentional ABI changes.
Measured unsigned legacy packet sizes with one signer: maximum-length seven-recipient preparation 792 bytes, activation 509 bytes, buy with idempotent ATA creation 508 bytes. Preparation and activation are separate transactions. These checks do not measure runtime compute, rent, wallet compatibility or real-validator lifecycle behavior; those remain separate release gates.
diff --git a/app/packages/solana-sdk/package.json b/app/packages/solana-sdk/package.json
index 1911abd1..54fdefec 100644
--- a/app/packages/solana-sdk/package.json
+++ b/app/packages/solana-sdk/package.json
@@ -9,6 +9,6 @@
"typecheck": "tsc --noEmit",
"verify-manifest": "tsx scripts/verify-manifest.ts"
},
- "dependencies": { "@solana/web3.js": "1.99.0", "@noble/hashes": "1.8.0", "buffer": "6.0.3" },
+ "dependencies": { "@solana/web3.js": "3.0.2", "@solana/keys": "8.5.0", "@noble/hashes": "1.8.0", "buffer": "6.0.3" },
"devDependencies": { "@types/node": "^22", "tsx": "4.23.13", "typescript": "5.9.3" }
}
diff --git a/app/packages/solana-sdk/scripts/print-abi-fixture.ts b/app/packages/solana-sdk/scripts/print-abi-fixture.ts
index 4881520e..5aa089ce 100644
--- a/app/packages/solana-sdk/scripts/print-abi-fixture.ts
+++ b/app/packages/solana-sdk/scripts/print-abi-fixture.ts
@@ -8,22 +8,22 @@ import { buildActivateLaunchInstruction, buildBuyInstruction, buildCancelPrepara
const programId = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
const creator = new PublicKey(new Uint8Array(32).fill(1));
const nonce = 7n;
-const a = derivePoolAddresses(programId, creator, nonce);
+const a = await derivePoolAddresses(programId, creator, nonce);
const prepare = { creator, nonce, virtualSol: 30_000_000_000n, feeBps: 0, name: "Example", symbol: "EX", uri: "", contentHash: new Uint8Array(32), recipients: [] };
const trade = { creator, nonce, trader: creator, amountIn: 1_000_000_000n, minimumOut: 10n, expirySlot: 1000n };
-const pool = Buffer.concat([discriminator("account", "Pool"), u8(1), u8(0), u8(a.bump), u64(nonce), creator.toBuffer(), creator.toBuffer(), a.mint.toBuffer(), u64(prepare.virtualSol), u16(0), u8(0), Buffer.alloc(7 * 50), u64(0n), u64(0n), Buffer.alloc(16), u64(0n), textField("Example", 32, "name"), textField("EX", 10, "symbol"), textField("", 200, "uri", true), Buffer.alloc(32)]);
+const pool = Buffer.concat([discriminator("account", "Pool"), u8(1), u8(0), u8(a.bump), u64(nonce), creator.toBytes(), creator.toBytes(), a.mint.toBytes(), u64(prepare.virtualSol), u16(0), u8(0), Buffer.alloc(7 * 50), u64(0n), u64(0n), Buffer.alloc(16), u64(0n), textField("Example", 32, "name"), textField("EX", 10, "symbol"), textField("", 200, "uri", true), Buffer.alloc(32)]);
const instructions = {
- prepareLaunch: buildPrepareLaunchInstruction(programId, prepare),
- activateLaunch: buildActivateLaunchInstruction(programId, creator, nonce),
- cancelPreparation: buildCancelPreparationInstruction(programId, creator, nonce),
- buyExactIn: buildBuyInstruction(programId, trade),
- sellExactIn: buildSellInstruction(programId, trade),
- claimFees: buildClaimFeesInstruction(programId, { creator, nonce, recipients: [{ address: creator, weightBps: 10000, paid: 0n }] }, 0),
+ prepareLaunch: await buildPrepareLaunchInstruction(programId, prepare),
+ activateLaunch: await buildActivateLaunchInstruction(programId, creator, nonce),
+ cancelPreparation: await buildCancelPreparationInstruction(programId, creator, nonce),
+ buyExactIn: await buildBuyInstruction(programId, trade),
+ sellExactIn: await buildSellInstruction(programId, trade),
+ claimFees: await buildClaimFeesInstruction(programId, { creator, nonce, recipients: [{ address: creator, weightBps: 10000, paid: 0n }] }, 0),
};
console.log(JSON.stringify({
note: "Undeployed fixture identity only; no private key or network activity.",
programId: programId.toBase58(), creator: creator.toBase58(), nonce: nonce.toString(),
addresses: Object.fromEntries(Object.entries(a).map(([key, value]) => [key, typeof value === "number" ? value : value.toBase58()])),
preparedPoolHex: pool.toString("hex"),
- instructions: Object.fromEntries(Object.entries(instructions).map(([key, ix]) => [key, { dataHex: ix.data.toString("hex"), keys: ix.keys.map((k) => ({ address: k.pubkey.toBase58(), signer: k.isSigner, writable: k.isWritable })) }])),
+ instructions: Object.fromEntries(Object.entries(instructions).map(([key, ix]) => [key, { dataHex: Buffer.from(ix.data).toString("hex"), keys: ix.keys.map((k) => ({ address: k.pubkey.toBase58(), signer: k.isSigner, writable: k.isWritable })) }])),
}, null, 2));
diff --git a/app/packages/solana-sdk/scripts/verify-manifest.ts b/app/packages/solana-sdk/scripts/verify-manifest.ts
index 3a032eb3..7194ee6b 100644
--- a/app/packages/solana-sdk/scripts/verify-manifest.ts
+++ b/app/packages/solana-sdk/scripts/verify-manifest.ts
@@ -8,7 +8,7 @@ let stage = "read manifest and reviewed ELF";
try {
const [manifestPath, elfPath, ...extra] = process.argv.slice(2);
if (!manifestPath || !elfPath || extra.length > 0) throw new Error("usage");
- const manifest = validateDeploymentManifest(JSON.parse(await readFile(manifestPath, "utf8")));
+ const manifest = await validateDeploymentManifest(JSON.parse(await readFile(manifestPath, "utf8")));
const elf = await readFile(elfPath);
stage = "connect to the explicitly configured read-only RPC";
const rpcUrl = process.env.SOLANA_VERIFY_RPC_URL;
@@ -21,20 +21,21 @@ try {
stage = "read finalized program and dependency accounts";
const programId = new PublicKey(manifest.programId);
const ownData = new PublicKey(manifest.programData);
- const tokenData = programDataAddress(TOKEN_PROGRAM_ID);
+ const tokenData = await programDataAddress(TOKEN_PROGRAM_ID);
const { context, value } = await connection.getMultipleAccountsInfoAndContext([programId, ownData, TOKEN_PROGRAM_ID, tokenData], { commitment: "finalized" });
const [program, programData, tokenProgram, tokenProgramData] = value;
if (!program || !programData || !tokenProgram || !tokenProgramData) throw new Error("Missing program accounts");
stage = "verify linked immutable launch and legacy Token programs";
- const own = decodeImmutableProgram(programId, program, ownData, programData);
- const token = decodeImmutableProgram(TOKEN_PROGRAM_ID, tokenProgram, tokenData, tokenProgramData);
+ const own = await decodeImmutableProgram(programId, program, ownData, programData);
+ const token = await decodeImmutableProgram(TOKEN_PROGRAM_ID, tokenProgram, tokenData, tokenProgramData);
stage = "compare deployed program bytes with the reviewed local ELF";
const hash = verifyDeploymentBytes(programData.data, elf, manifest.binarySha256);
+ if (context.slot < 0n || context.slot > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Unsafe evidence slot");
console.log(JSON.stringify({
schema: 1, evidence: "read-only-rpc-and-binary-check", observedAt: new Date().toISOString(),
cluster: manifest.cluster, genesisHash: manifest.genesisHash, programId: manifest.programId,
programData: manifest.programData, sourceCommit: manifest.sourceCommit, binarySha256: hash,
- verifiedSlot: context.slot, deploymentSlot: own.deploymentSlot.toString(), upgradeAuthority: null,
+ verifiedSlot: Number(context.slot), deploymentSlot: own.deploymentSlot.toString(), upgradeAuthority: null,
tokenProgram: TOKEN_PROGRAM_ID.toBase58(), tokenProgramData: tokenData.toBase58(), tokenDeploymentSlot: token.deploymentSlot.toString(), tokenUpgradeAuthority: null,
releaseApproved: false,
limitation: "This evidence is not an audit, source-to-binary reproducibility proof, or release approval. Independently verify using another trusted RPC and retain the build attestation.",
diff --git a/app/packages/solana-sdk/src/accounts.ts b/app/packages/solana-sdk/src/accounts.ts
index d9b5792a..bb3ad71a 100644
--- a/app/packages/solana-sdk/src/accounts.ts
+++ b/app/packages/solana-sdk/src/accounts.ts
@@ -1,6 +1,6 @@
import { Buffer } from "buffer";
import { PublicKey, type AccountInfo, type Connection } from "@solana/web3.js";
-import { discriminator, Reader, u64, utf8 } from "./encoding.ts";
+import { discriminator, Reader, rpcSafeNumber, 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";
@@ -18,18 +18,20 @@ export type PoolAddresses = {
pool: PublicKey; mint: PublicKey; tokenVault: PublicKey; reserve: PublicKey;
fees: PublicKey; mintAuthority: PublicKey; bump: number;
};
-export function derivePoolAddresses(programId: PublicKey, creator: PublicKey, nonce: bigint): PoolAddresses {
- const [pool, bump] = PublicKey.findProgramAddressSync([utf8.encode("pool"), creator.toBytes(), u64(nonce)], programId);
- 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") };
+export async function derivePoolAddresses(programId: PublicKey, creator: PublicKey, nonce: bigint): Promise {
+ const [pool, bump] = await PublicKey.findProgramAddress([utf8.encode("pool"), creator.toBytes(), u64(nonce)], programId);
+ const derive = async (seed: string) => (await PublicKey.findProgramAddress([utf8.encode(seed), pool.toBytes()], programId))[0];
+ const [mint, tokenVault, reserve, fees, mintAuthority] = await Promise.all(["mint", "token", "reserve", "fees", "mint-authority"].map(derive));
+ return { pool, bump, mint, tokenVault, reserve, fees, mintAuthority };
}
/** 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 async function deriveCustodyAddresses(programId: PublicKey, pool: PublicKey): Promise> {
+ const derive = async (seed: string) => (await PublicKey.findProgramAddress([utf8.encode(seed), pool.toBytes()], programId))[0];
+ const [mint, tokenVault, reserve, fees] = await Promise.all(["mint", "token", "reserve", "fees"].map(derive));
+ return { mint, tokenVault, reserve, 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];
+export async function deriveAssociatedTokenAddress(mint: PublicKey, owner: PublicKey): Promise {
+ return (await PublicKey.findProgramAddress([owner.toBytes(), TOKEN_PROGRAM_ID.toBytes(), mint.toBytes()], ASSOCIATED_TOKEN_PROGRAM_ID))[0];
}
export type Recipient = { address: PublicKey; weightBps: number; paid: bigint };
export type PoolAccount = CurveState & {
@@ -39,7 +41,7 @@ export type PoolAccount = CurveState & {
};
/** Owner and canonical PDA validation are required, not optional decoder flags. */
-export function decodePoolAccount(address: PublicKey, info: AccountInfo, programId: PublicKey): PoolAccount {
+export async function decodePoolAccount(address: PublicKey, info: AccountInfo, programId: PublicKey): Promise {
if (!info.owner.equals(programId) || info.executable) throw new Error("Wrong pool owner");
if (info.data.length > POOL_ACCOUNT_SPACE) throw new Error("Unsupported pool allocation size");
const r = new Reader(info.data);
@@ -57,7 +59,7 @@ export function decodePoolAccount(address: PublicKey, info: AccountInfo,
}
const tokenInventory = r.long(); const realSolReserves = r.long(); const feesEarned = r.wide(); const sequence = r.long();
const name = r.text(32); const symbol = r.text(10); const uri = r.text(200, true); const contentHash = new Uint8Array(r.take(32)); r.finish();
- const addresses = derivePoolAddresses(programId, creator, nonce);
+ const addresses = await derivePoolAddresses(programId, creator, nonce);
if (!address.equals(addresses.pool) || bump !== addresses.bump || !mint.equals(addresses.mint)) throw new Error("Noncanonical pool or mint");
validateRecipients(recipients, feeBps, addresses, programId);
for (const recipient of recipients) claimableFees(feesEarned, recipient.weightBps, recipient.paid);
@@ -81,31 +83,31 @@ export function validateRecipients(recipients: ReadonlyArray<{ address: PublicKe
}
if (feeBps !== 0 && recipients.reduce((sum, r) => sum + r.weightBps, 0) !== 10_000) throw new Error("Recipient weights must total 10000");
}
-export function programDataAddress(programId: PublicKey): PublicKey {
- return PublicKey.findProgramAddressSync([programId.toBytes()], UPGRADEABLE_LOADER_ID)[0];
+export async function programDataAddress(programId: PublicKey): Promise {
+ return (await PublicKey.findProgramAddress([programId.toBytes()], UPGRADEABLE_LOADER_ID))[0];
}
-export function decodeImmutableProgram(programId: PublicKey, executable: AccountInfo, dataAddress: PublicKey, data: AccountInfo): { deploymentSlot: bigint } {
- if (!executable.executable || !executable.owner.equals(UPGRADEABLE_LOADER_ID) || executable.data.length !== 36 || executable.data.readUInt32LE(0) !== 2) throw new Error("Unsupported executable loader state");
+export async function decodeImmutableProgram(programId: PublicKey, executable: AccountInfo, dataAddress: PublicKey, data: AccountInfo): Promise<{ deploymentSlot: bigint }> {
+ const executableBytes = Buffer.from(executable.data); const dataBytes = Buffer.from(data.data);
+ if (!executable.executable || !executable.owner.equals(UPGRADEABLE_LOADER_ID) || executableBytes.length !== 36 || executableBytes.readUInt32LE(0) !== 2) throw new Error("Unsupported executable loader state");
const recorded = new PublicKey(executable.data.subarray(4, 36));
- if (!recorded.equals(dataAddress) || !dataAddress.equals(programDataAddress(programId))) throw new Error("ProgramData does not belong to this program");
- if (data.executable || !data.owner.equals(UPGRADEABLE_LOADER_ID) || data.data.length < 45 || data.data.readUInt32LE(0) !== 3) throw new Error("Invalid ProgramData");
+ if (!recorded.equals(dataAddress) || !dataAddress.equals(await programDataAddress(programId))) throw new Error("ProgramData does not belong to this program");
+ if (data.executable || !data.owner.equals(UPGRADEABLE_LOADER_ID) || dataBytes.length < 45 || dataBytes.readUInt32LE(0) !== 3) throw new Error("Invalid ProgramData");
if (data.data[12] !== 0) throw new Error("Program still has an upgrade authority");
- return { deploymentSlot: data.data.readBigUInt64LE(4) };
+ return { deploymentSlot: dataBytes.readBigUInt64LE(4) };
}
export async function verifyImmutableProgram(connection: Connection, programId: PublicKey): Promise<{ programData: PublicKey; deploymentSlot: bigint; slot: number }> {
- const programData = programDataAddress(programId);
+ const programData = await programDataAddress(programId);
// 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 };
+ return { programData, ...await decodeImmutableProgram(programId, value[0], programData, value[1]), slot: rpcSafeNumber(context.slot, "Program slot") };
}
-function actualLamports(info: AccountInfo): bigint {
- // web3.js v1 exposes RPC lamports as number: refuse unsafe rounding instead of inventing precision.
- if (!Number.isSafeInteger(info.lamports) || info.lamports < 0) throw new Error("RPC lamport balance exceeds safe integer precision");
- return BigInt(info.lamports);
+function actualLamports(info: AccountInfo): bigint {
+ if (typeof info.lamports !== "bigint" || info.lamports < 0n || info.lamports > (1n << 64n) - 1n) throw new Error("Invalid RPC lamport balance");
+ return info.lamports;
}
-function verifySolVault(info: AccountInfo, programId: PublicKey, pool: PublicKey, kind: number, liability: bigint): void {
+function verifySolVault(info: AccountInfo, programId: PublicKey, pool: PublicKey, kind: number, liability: bigint): void {
if (!info.owner.equals(programId) || info.executable || info.data.length !== SOL_VAULT_SPACE) throw new Error("Invalid SOL vault owner or size");
const r = new Reader(info.data);
if (!r.take(8).equals(discriminator("account", "SolVault")) || !r.key().equals(pool) || r.byte() !== kind) throw new Error("Invalid SOL vault linkage");
@@ -122,26 +124,28 @@ export async function fetchPoolSnapshot(connection: Connection, programId: Publi
* 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)[] }> {
+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);
+ const custody = await deriveCustodyAddresses(programId, address);
// One bank/context for state and custody; never mix independent vault read slots.
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) };
+ return { snapshot: await validatedSnapshot(programId, address, value, rpcSafeNumber(context.slot, "Snapshot 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 {
+async function validatedSnapshot(programId: PublicKey, address: PublicKey, value: (AccountInfo | null)[], slot: number): Promise {
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);
+ const pool = await 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);
+ const addresses = await derivePoolAddresses(programId, pool.creator, pool.nonce);
if (pool.status === "active") {
- const [, mint, token, reserve, fees] = value;
+ const [, rawMint, rawToken, reserve, fees] = value;
+ const mint = rawMint && { ...rawMint, data: Buffer.from(rawMint.data) };
+ const token = rawToken && { ...rawToken, data: Buffer.from(rawToken.data) };
if (!mint || !token || !reserve || !fees) throw new Error("Missing active custody accounts");
if (!mint.owner.equals(TOKEN_PROGRAM_ID) || mint.executable || mint.data.length !== 82 || mint.data.readUInt32LE(0) !== 0 || mint.data[44] !== TOKEN_DECIMALS || mint.data[45] !== 1 || mint.data.readUInt32LE(46) !== 0 || mint.data.readBigUInt64LE(36) > FIXED_SUPPLY) throw new Error("Invalid mint authorities or supply");
if (!token.owner.equals(TOKEN_PROGRAM_ID) || token.executable || token.data.length !== 165 || !new PublicKey(token.data.subarray(0, 32)).equals(addresses.mint) || !new PublicKey(token.data.subarray(32, 64)).equals(address) || token.data[108] !== 1 || token.data.readUInt32LE(72) !== 0 || token.data.readUInt32LE(109) !== 0 || token.data.readUInt32LE(129) !== 0 || token.data.readBigUInt64LE(64) < pool.tokenInventory) throw new Error("Invalid token vault custody");
@@ -170,15 +174,15 @@ export async function fetchActivePools(connection: Connection, programId: Public
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 pools = (await Promise.all(keys.map(async (address, i) => {
const info = infos[i];
if (!info) return [];
try {
- const pool = decodePoolAccount(address, info, programId);
+ const pool = await decodePoolAccount(address, info, programId);
return pool.status === "active" ? [{ address, pool }] : [];
} catch {
return [];
}
- });
+ }))).flat();
return { total: rows.length, pools };
}
diff --git a/app/packages/solana-sdk/src/encoding.ts b/app/packages/solana-sdk/src/encoding.ts
index 9405b739..039a81b8 100644
--- a/app/packages/solana-sdk/src/encoding.ts
+++ b/app/packages/solana-sdk/src/encoding.ts
@@ -4,6 +4,11 @@ import { PublicKey } from "@solana/web3.js";
import { bounded, U64_MAX } from "./math.ts";
export const utf8 = new TextEncoder();
+/** Preserve existing JSON/journal number fields without rounding v3 RPC integers. */
+export function rpcSafeNumber(value: bigint, label: string): number {
+ if (typeof value !== "bigint" || value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError(`${label} exceeds safe integer precision`);
+ return Number(value);
+}
export const discriminator = (namespace: "global" | "account" | "event", name: string): Buffer =>
Buffer.from(sha256(utf8.encode(`${namespace}:${name}`)).slice(0, 8));
export function u8(n: number): Buffer {
@@ -29,7 +34,8 @@ export function textField(text: string, maxBytes: number, label: string, allowEm
}
export class Reader {
offset = 0;
- constructor(readonly data: Buffer) {}
+ readonly data: Buffer;
+ constructor(data: Uint8Array) { this.data = Buffer.from(data); }
take(length: number): Buffer {
if (!Number.isSafeInteger(length) || length < 0 || this.offset + length > this.data.length) throw new Error("Truncated account data");
const result = this.data.subarray(this.offset, this.offset + length); this.offset += length; return result;
diff --git a/app/packages/solana-sdk/src/instructions.ts b/app/packages/solana-sdk/src/instructions.ts
index 9b065e8a..33a6cb68 100644
--- a/app/packages/solana-sdk/src/instructions.ts
+++ b/app/packages/solana-sdk/src/instructions.ts
@@ -12,43 +12,43 @@ export type PrepareLaunchArgs = {
name: string; symbol: string; uri: string; contentHash: Uint8Array;
recipients: ReadonlyArray<{ address: PublicKey; weightBps: number }>;
};
-export function buildPrepareLaunchInstruction(programId: PublicKey, args: PrepareLaunchArgs): TransactionInstruction {
- const addresses = derivePoolAddresses(programId, args.creator, args.nonce);
+export async function buildPrepareLaunchInstruction(programId: PublicKey, args: PrepareLaunchArgs): Promise {
+ const addresses = await derivePoolAddresses(programId, args.creator, args.nonce);
validateRecipients(args.recipients, args.feeBps, addresses, programId);
bounded(args.virtualSol, MAX_VIRTUAL_SOL, "Virtual SOL");
if (args.virtualSol < MIN_VIRTUAL_SOL || args.contentHash.length !== 32) throw new Error("Invalid valuation or content hash");
return instruction(programId, "prepare_launch", [meta(args.creator, false, true), meta(args.payer ?? args.creator, true, true), meta(addresses.pool, true), meta(SystemProgram.programId)], [
u64(args.nonce), u64(args.virtualSol), u16(args.feeBps), textField(args.name, 32, "name"), textField(args.symbol, 10, "symbol"), textField(args.uri, 200, "URI", true), Buffer.from(args.contentHash),
- u32(args.recipients.length), ...args.recipients.flatMap((r) => [r.address.toBuffer(), u16(r.weightBps)]),
+ u32(args.recipients.length), ...args.recipients.flatMap((r) => [Buffer.from(r.address.toBytes()), u16(r.weightBps)]),
]);
}
-export function buildActivateLaunchInstruction(programId: PublicKey, creator: PublicKey, nonce: bigint): TransactionInstruction {
- const a = derivePoolAddresses(programId, creator, nonce);
- return instruction(programId, "activate_launch", [meta(creator, true, true), meta(a.pool, true), meta(a.mint, true), meta(a.tokenVault, true), meta(a.reserve, true), meta(a.fees, true), meta(a.mintAuthority), meta(programId), meta(programDataAddress(programId)), meta(TOKEN_PROGRAM_ID), meta(SystemProgram.programId), meta(SYSVAR_RENT_PUBKEY)]);
+export async function buildActivateLaunchInstruction(programId: PublicKey, creator: PublicKey, nonce: bigint): Promise {
+ const a = await derivePoolAddresses(programId, creator, nonce);
+ return instruction(programId, "activate_launch", [meta(creator, true, true), meta(a.pool, true), meta(a.mint, true), meta(a.tokenVault, true), meta(a.reserve, true), meta(a.fees, true), meta(a.mintAuthority), meta(programId), meta(await programDataAddress(programId)), meta(TOKEN_PROGRAM_ID), meta(SystemProgram.programId), meta(SYSVAR_RENT_PUBKEY)]);
}
-export function buildCancelPreparationInstruction(programId: PublicKey, creator: PublicKey, nonce: bigint): TransactionInstruction {
- return instruction(programId, "cancel_preparation", [meta(creator, false, true), meta(derivePoolAddresses(programId, creator, nonce).pool, true)]);
+export async function buildCancelPreparationInstruction(programId: PublicKey, creator: PublicKey, nonce: bigint): Promise {
+ return instruction(programId, "cancel_preparation", [meta(creator, false, true), meta((await derivePoolAddresses(programId, creator, nonce)).pool, true)]);
}
export type TradeInstructionArgs = {
creator: PublicKey; nonce: bigint; trader: PublicKey; traderToken?: PublicKey;
amountIn: bigint; minimumOut: bigint; expirySlot: bigint;
};
-function tradeInstruction(programId: PublicKey, name: string, args: TradeInstructionArgs): TransactionInstruction {
- const a = derivePoolAddresses(programId, args.creator, args.nonce);
+async function tradeInstruction(programId: PublicKey, name: string, args: TradeInstructionArgs): Promise {
+ const a = await derivePoolAddresses(programId, args.creator, args.nonce);
bounded(args.amountIn, U64_MAX, "Amount in"); bounded(args.minimumOut, U64_MAX, "Minimum out");
if (args.amountIn === 0n || args.minimumOut === 0n) throw new Error("Input and slippage minimum must be positive");
- const traderToken = args.traderToken ?? deriveAssociatedTokenAddress(a.mint, args.trader);
+ const traderToken = args.traderToken ?? await deriveAssociatedTokenAddress(a.mint, args.trader);
return instruction(programId, name, [meta(args.trader, true, true), meta(a.pool, true), meta(a.mint), meta(a.tokenVault, true), meta(a.reserve, true), meta(a.fees, true), meta(traderToken, true), meta(TOKEN_PROGRAM_ID), meta(SystemProgram.programId)], [u64(args.amountIn), u64(args.minimumOut), u64(args.expirySlot)]);
}
-export const buildBuyInstruction = (programId: PublicKey, args: TradeInstructionArgs): TransactionInstruction => tradeInstruction(programId, "buy_exact_in", args);
-export const buildSellInstruction = (programId: PublicKey, args: TradeInstructionArgs): TransactionInstruction => tradeInstruction(programId, "sell_exact_in", args);
-export function buildClaimFeesInstruction(programId: PublicKey, pool: Pick, recipientIndex: number): TransactionInstruction {
+export const buildBuyInstruction = (programId: PublicKey, args: TradeInstructionArgs): Promise => tradeInstruction(programId, "buy_exact_in", args);
+export const buildSellInstruction = (programId: PublicKey, args: TradeInstructionArgs): Promise => tradeInstruction(programId, "sell_exact_in", args);
+export async function buildClaimFeesInstruction(programId: PublicKey, pool: Pick, recipientIndex: number): Promise {
if (!Number.isInteger(recipientIndex) || recipientIndex < 0 || recipientIndex >= pool.recipients.length) throw new Error("Invalid recipient index");
- const a = derivePoolAddresses(programId, pool.creator, pool.nonce);
+ const a = await derivePoolAddresses(programId, pool.creator, pool.nonce);
return instruction(programId, "claim_fees", [meta(a.pool, true), meta(a.fees, true), meta(pool.recipients[recipientIndex].address, true)], [u8(recipientIndex)]);
}
/** Idempotent official ATA creation. Rent/fees are extra; this never wraps SOL. */
-export function buildCreateAssociatedTokenInstruction(payer: PublicKey, mint: PublicKey, owner: PublicKey): TransactionInstruction {
+export async function buildCreateAssociatedTokenInstruction(payer: PublicKey, mint: PublicKey, owner: PublicKey): Promise {
return new TransactionInstruction({ programId: ASSOCIATED_TOKEN_PROGRAM_ID,
- keys: [meta(payer, true, true), meta(deriveAssociatedTokenAddress(mint, owner), true), meta(owner), meta(mint), meta(SystemProgram.programId), meta(TOKEN_PROGRAM_ID)], data: Buffer.from([1]) });
+ keys: [meta(payer, true, true), meta(await deriveAssociatedTokenAddress(mint, owner), true), meta(owner), meta(mint), meta(SystemProgram.programId), meta(TOKEN_PROGRAM_ID)], data: Buffer.from([1]) });
}
diff --git a/app/packages/solana-sdk/src/transactions.ts b/app/packages/solana-sdk/src/transactions.ts
index 219f10e9..ea29f5e8 100644
--- a/app/packages/solana-sdk/src/transactions.ts
+++ b/app/packages/solana-sdk/src/transactions.ts
@@ -1,5 +1,7 @@
import { Buffer } from "buffer";
-import { PublicKey, SendTransactionError, Transaction, VersionedTransaction, type Connection, type TransactionInstruction } from "@solana/web3.js";
+import { Message, PublicKey, SendTransactionError, Transaction, VersionedTransaction, type Connection, type TransactionInstruction } from "@solana/web3.js";
+import { signatureBytes, verifySignature } from "@solana/keys";
+import { rpcSafeNumber } from "./encoding.ts";
export const MAX_TRANSACTION_BYTES = 1232;
/** `PoolError` messages in program order: Anchor numbers custom errors from 6000. */
@@ -21,8 +23,10 @@ export function describeTransactionError(err: unknown, logs: readonly string[] =
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);
+ // v3's lossless RPC parser can promote nested InstructionError fields to bigint too.
+ const text = typeof err === "string" ? err : JSON.stringify(err, (_key, value) => typeof value === "bigint" ? value.toString() : value) ?? "";
+ // SendTransactionError also humanizes the structured code when no Anchor log is available.
+ const custom = /"Custom":"?(\d+)/.exec(text) ?? /Custom program error: #(\d+)/.exec(text);
if (custom) return POOL_ERRORS[Number(custom[1]) - 6000] ?? `Program error ${custom[1]}`;
return text;
}
@@ -33,16 +37,18 @@ export type PreparedTransaction = {
export async function prepareTransaction(connection: Connection, payer: PublicKey, instructions: TransactionInstruction[]): Promise {
if (instructions.length === 0) throw new Error("Transaction has no instructions");
const latest = await connection.getLatestBlockhash("confirmed");
+ const lastValidBlockHeight = rpcSafeNumber(latest.lastValidBlockHeight, "Transaction expiry");
+ if (lastValidBlockHeight === 0) throw new Error("Invalid transaction expiry");
const transaction = new Transaction({ feePayer: payer, recentBlockhash: latest.blockhash }).add(...instructions);
- const wireBytes = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
+ const wireBytes = await transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
if (wireBytes.length > MAX_TRANSACTION_BYTES) throw new Error("Transaction exceeds Solana packet budget");
- return { transaction, wireBytes, messageBytes: transaction.serializeMessage(), ...latest };
+ return { transaction, wireBytes, messageBytes: transaction.serializeMessage(), blockhash: latest.blockhash, lastValidBlockHeight };
}
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: ${describeTransactionError(result.value.err, result.value.logs ?? [])}`);
- return { unitsConsumed: result.value.unitsConsumed, logs: result.value.logs ?? [] };
+ return { unitsConsumed: result.value.unitsConsumed === undefined ? undefined : rpcSafeNumber(result.value.unitsConsumed, "Simulation units"), logs: result.value.logs ?? [] };
}
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
@@ -56,14 +62,39 @@ export function encodeBase58(bytes: Uint8Array): string {
return result;
}
export type ValidatedSignedTransaction = { bytes: Uint8Array; signature: string };
+/**
+ * Strictly verify a private snapshot of every required signature. web3.js 3.0.2 imports verification keys as
+ * non-extractable, incompatible with Kit 8.5's small-order point protection. Keep that protection: import public
+ * keys as extractable and use the official strict verifier, never downgrade Kit or trust a truthy Promise.
+ * Callers must not mutate a transaction during verification; the wallet boundary below owns its parsed copy.
+ */
+export async function verifyTransactionSignatures(transaction: Transaction): Promise {
+ const messageBytes = Uint8Array.from(transaction.serializeMessage());
+ const message = Message.from(messageBytes);
+ const required = message.header.numRequiredSignatures;
+ const signatures = transaction.signatures.map(({ publicKey, signature }) => ({ publicKey: new PublicKey(publicKey.toBytes()), signature: signature && Uint8Array.from(signature) }));
+ if (required < 1 || signatures.length !== required) return false;
+ for (let i = 0; i < required; i++) {
+ const { publicKey, signature } = signatures[i];
+ if (!publicKey.equals(message.accountKeys[i]) || !signature || signature.length !== 64) return false;
+ const key = await crypto.subtle.importKey("raw", Uint8Array.from(publicKey.toBytes()), { name: "Ed25519" }, true, ["verify"]);
+ if (!await verifySignature(key, signatureBytes(signature), messageBytes)) return false;
+ }
+ return true;
+}
/** Reject changed recipients, amounts, blockhashes, added instructions and missing/invalid signatures. */
-export function validateSignedTransaction(expectedMessage: Uint8Array, signedBytes: Uint8Array, payer: PublicKey): ValidatedSignedTransaction {
+export async function validateSignedTransaction(expectedMessage: Uint8Array, signedBytes: Uint8Array, payer: PublicKey): Promise {
if (signedBytes.length > MAX_TRANSACTION_BYTES || signedBytes.length < 1) throw new Error("Invalid signed transaction size");
- const transaction = Transaction.from(signedBytes);
- if (!transaction.feePayer?.equals(payer) || !transaction.serializeMessage().equals(Buffer.from(expectedMessage))) throw new Error("Wallet changed the transaction message");
- if (!transaction.verifySignatures(true) || !transaction.signature) throw new Error("Wallet returned missing or invalid signatures");
- const bytes = transaction.serialize({ requireAllSignatures: true, verifySignatures: true });
- return { bytes, signature: encodeBase58(transaction.signature) };
+ // Do not retain mutable wallet-owned arrays across asynchronous signature verification.
+ const wireSnapshot = Uint8Array.from(signedBytes);
+ const transaction = Transaction.from(wireSnapshot);
+ if (!transaction.feePayer?.equals(payer) || !Buffer.from(transaction.serializeMessage()).equals(Buffer.from(expectedMessage))) throw new Error("Wallet changed the transaction message");
+ // Serialization here is only an encoding check, not authorization. Strict signatures are checked below.
+ const canonical = await transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
+ if (!Buffer.from(canonical).equals(Buffer.from(wireSnapshot))) throw new Error("Wallet returned a noncanonical transaction encoding");
+ if (!await verifyTransactionSignatures(transaction) || !transaction.signature) throw new Error("Wallet returned missing or invalid signatures");
+ // Return exactly the private wire snapshot we verified, never re-read wallet-owned bytes after an await.
+ return { bytes: wireSnapshot, signature: encodeBase58(transaction.signature) };
}
export type SubmissionResult = { signature: string; status: "submitted" | "rejected" | "unknown"; error?: string };
/**
@@ -79,9 +110,12 @@ export async function submitSignedTransaction(connection: Connection, signed: Va
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 (e) {
- if (e instanceof SendTransactionError && !/AlreadyProcessed/.test(e.transactionError.message ?? ""))
+ if (e instanceof SendTransactionError && !/AlreadyProcessed|already been processed/i.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))
+ // Kit's HTTP transport exposes a structured SolanaError, rather than v1's "429 ..." Error text.
+ const context = e instanceof Error && "context" in e && e.context && typeof e.context === "object" ? e.context as Record : null;
+ const rateLimited = context?.__code === 8100002 && context.statusCode === 429;
+ if (!(e instanceof SendTransactionError) && e instanceof Error && (rateLimited || /^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." };
}
@@ -112,9 +146,9 @@ export async function reconcileTransaction(connection: Connection, signature: st
return { status: "pending" };
}
const height = finalized.blockHeight;
- if (height === undefined || !Number.isSafeInteger(height) || height <= lastValidBlockHeight) return { status: "pending", unseen: true };
+ if (height === undefined || typeof height !== "bigint" || height < 0n || height <= BigInt(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 };
+ if (typeof result.context.slot !== "bigint" || typeof finalized.absoluteSlot !== "bigint" || result.context.slot < 0n || finalized.absoluteSlot < 0n || result.context.slot < finalized.absoluteSlot) return { status: "pending", unseen: true };
return { status: "expired" };
}
diff --git a/app/packages/solana-sdk/src/verification.ts b/app/packages/solana-sdk/src/verification.ts
index 6c56c6bf..f8bd3bc1 100644
--- a/app/packages/solana-sdk/src/verification.ts
+++ b/app/packages/solana-sdk/src/verification.ts
@@ -13,13 +13,13 @@ export const CLUSTER_GENESIS = {
"mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
} as const;
-export function validateDeploymentManifest(input: unknown): DeploymentManifest {
+export async function validateDeploymentManifest(input: unknown): Promise {
if (!input || typeof input !== "object") throw new Error("Invalid manifest");
const m = input as Record;
if (m.schema !== 1 || !["mainnet-beta", "devnet"].includes(String(m.cluster)) || typeof m.programId !== "string" || typeof m.programData !== "string" || typeof m.genesisHash !== "string" || typeof m.sourceCommit !== "string" || !/^[0-9a-f]{40}$/.test(m.sourceCommit) || typeof m.binarySha256 !== "string" || !/^[0-9a-f]{64}$/.test(m.binarySha256)) throw new Error("Manifest needs exact program, cluster, genesis, source commit and binary hash");
const program = new PublicKey(m.programId);
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 (!(await 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");
diff --git a/app/packages/solana-sdk/tests/accounts.test.ts b/app/packages/solana-sdk/tests/accounts.test.ts
index d3ae5db0..5d3e0d68 100644
--- a/app/packages/solana-sdk/tests/accounts.test.ts
+++ b/app/packages/solana-sdk/tests/accounts.test.ts
@@ -10,44 +10,44 @@ import { FIXED_SUPPLY } from "../src/math.ts";
const programId = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
const creator = new PublicKey(new Uint8Array(32).fill(1));
const nonce = 7n;
-const a = derivePoolAddresses(programId, creator, nonce);
-function account(data: Buffer, owner = programId, lamports = 10000000): AccountInfo { return { data, owner, lamports, executable: false, rentEpoch: 0 }; }
+const a = await derivePoolAddresses(programId, creator, nonce);
+function account(data: Buffer, owner = programId, lamports = 10000000n): AccountInfo { return { data, owner, lamports, executable: false, rentEpoch: 0n }; }
function poolBytes(active = false): Buffer {
- return Buffer.concat([discriminator("account", "Pool"), u8(1), u8(active ? 1 : 0), u8(a.bump), u64(nonce), creator.toBuffer(), creator.toBuffer(), a.mint.toBuffer(), u64(30_000_000_000n), u16(0), u8(0), Buffer.alloc(7 * 50), u64(active ? FIXED_SUPPLY : 0n), u64(0n), Buffer.alloc(16), u64(0n), textField("Example", 32, "name"), textField("EX", 10, "symbol"), textField("", 200, "uri", true), Buffer.alloc(32)]);
+ return Buffer.concat([discriminator("account", "Pool"), u8(1), u8(active ? 1 : 0), u8(a.bump), u64(nonce), creator.toBytes(), creator.toBytes(), a.mint.toBytes(), u64(30_000_000_000n), u16(0), u8(0), Buffer.alloc(7 * 50), u64(active ? FIXED_SUPPLY : 0n), u64(0n), Buffer.alloc(16), u64(0n), textField("Example", 32, "name"), textField("EX", 10, "symbol"), textField("", 200, "uri", true), Buffer.alloc(32)]);
}
-test("Pool decoding preserves case-sensitive addresses, empty URI, exact reserves and PDA", () => {
- const pool = decodePoolAccount(a.pool, account(poolBytes()), programId);
+test("Pool decoding preserves case-sensitive addresses, empty URI, exact reserves and PDA", async () => {
+ const pool = await decodePoolAccount(a.pool, account(poolBytes()), programId);
assert.equal(pool.status, "prepared"); assert.equal(pool.uri, ""); assert.equal(pool.nonce, 7n);
assert.equal(pool.creator.toBase58(), creator.toBase58());
- assert.equal(decodePoolAccount(a.pool, account(poolBytes(true)), programId).tokenInventory, FIXED_SUPPLY);
- assert.throws(() => decodePoolAccount(creator, account(poolBytes()), programId), /Noncanonical/);
- assert.throws(() => decodePoolAccount(a.pool, account(poolBytes(), TOKEN_PROGRAM_ID), programId), /owner/);
- assert.throws(() => decodePoolAccount(a.pool, account(poolBytes().subarray(0, 40)), programId), /Truncated/);
- assert.throws(() => decodePoolAccount(a.pool, account(Buffer.concat([poolBytes(), Buffer.from([1])])), programId), /suffix/);
+ assert.equal((await decodePoolAccount(a.pool, account(poolBytes(true)), programId)).tokenInventory, FIXED_SUPPLY);
+ await assert.rejects(decodePoolAccount(creator, account(poolBytes()), programId), /Noncanonical/);
+ await assert.rejects(decodePoolAccount(a.pool, account(poolBytes(), TOKEN_PROGRAM_ID), programId), /owner/);
+ await assert.rejects(decodePoolAccount(a.pool, account(poolBytes().subarray(0, 40)), programId), /Truncated/);
+ await assert.rejects(decodePoolAccount(a.pool, account(Buffer.concat([poolBytes(), Buffer.from([1])])), programId), /suffix/);
});
-test("immutable check requires own linked ProgramData, expected loader and null authority", () => {
- const dataAddress = programDataAddress(programId);
- const exe = account(Buffer.concat([Buffer.from([2, 0, 0, 0]), dataAddress.toBuffer()]), UPGRADEABLE_LOADER_ID); exe.executable = true;
+test("immutable check requires own linked ProgramData, expected loader and null authority", async () => {
+ const dataAddress = await programDataAddress(programId);
+ const exe = account(Buffer.concat([Buffer.from([2, 0, 0, 0]), dataAddress.toBytes()]), UPGRADEABLE_LOADER_ID); exe.executable = true;
const data = account(Buffer.concat([Buffer.from([3, 0, 0, 0]), u64(123n), Buffer.alloc(33), Buffer.alloc(100)]), UPGRADEABLE_LOADER_ID);
- assert.equal(decodeImmutableProgram(programId, exe, dataAddress, data).deploymentSlot, 123n);
- assert.throws(() => decodeImmutableProgram(programId, exe, a.pool, data), /belong/);
- data.data[12] = 1; assert.throws(() => decodeImmutableProgram(programId, exe, dataAddress, data), /upgrade authority/);
- data.data[12] = 0; data.owner = creator; assert.throws(() => decodeImmutableProgram(programId, exe, dataAddress, data), /ProgramData/);
+ assert.equal((await decodeImmutableProgram(programId, exe, dataAddress, data)).deploymentSlot, 123n);
+ await assert.rejects(decodeImmutableProgram(programId, exe, a.pool, data), /belong/);
+ data.data[12] = 1; await assert.rejects(decodeImmutableProgram(programId, exe, dataAddress, data), /upgrade authority/);
+ data.data[12] = 0; data.owner = creator; await assert.rejects(decodeImmutableProgram(programId, exe, dataAddress, data), /ProgramData/);
});
function mintBytes(): Buffer {
const b = Buffer.alloc(82); b.writeBigUInt64LE(FIXED_SUPPLY, 36); b[44] = 6; b[45] = 1; return b;
}
function tokenBytes(): Buffer {
- const b = Buffer.alloc(165); a.mint.toBuffer().copy(b, 0); a.pool.toBuffer().copy(b, 32); b.writeBigUInt64LE(FIXED_SUPPLY, 64); b[108] = 1; return b;
+ const b = Buffer.alloc(165); b.set(a.mint.toBytes(), 0); b.set(a.pool.toBytes(), 32); b.writeBigUInt64LE(FIXED_SUPPLY, 64); b[108] = 1; return b;
}
-function vaultBytes(kind: number): Buffer { return Buffer.concat([discriminator("account", "SolVault"), a.pool.toBuffer(), u8(kind), u64(1000n)]); }
+function vaultBytes(kind: number): Buffer { return Buffer.concat([discriminator("account", "SolVault"), a.pool.toBytes(), u8(kind), u64(1000n)]); }
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 = {
- getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => { calls++; requested = keys; return { context: { slot: 5 }, value: [...values, ...keys.slice(5).map(() => null)] }; },
+ getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => { calls++; requested = keys; return { context: { slot: 5n }, value: [...values.map((info) => ({ ...info, data: new Uint8Array(info.data) })), ...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.
@@ -60,10 +60,14 @@ test("snapshot accepts donated surplus, rejects invalid custody and reads one co
values[2].data.writeUInt32LE(1, 72);
await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /custody/);
values[2].data.writeUInt32LE(0, 72);
- values[3].lamports = 1;
+ values[3].lamports = 1n;
await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /underfunded/);
- values[3].lamports = Number.MAX_SAFE_INTEGER + 1;
- await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /precision/);
+ values[3].lamports = BigInt(Number.MAX_SAFE_INTEGER) + 2n;
+ assert.equal((await fetchPoolSnapshot(fake, programId, a.pool)).pool.realSolReserves, 0n);
+ values[3].lamports = -1n;
+ await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /lamport/);
+ values[3].lamports = 1n << 64n;
+ await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /lamport/);
});
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);
@@ -89,15 +93,26 @@ test("the pool list filters active pools on-chain and reads data only for the ro
});
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;
+ const fake = { getMultipleAccountsInfoAndContext: async (_keys: PublicKey[], config: typeof given) => { given = config; return { context: { slot: 1n }, 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;
+ const missing = { getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => ({ context: { slot: 1n }, 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;
+ const other = { getMultipleAccountsInfoAndContext: async (keys: PublicKey[]) => ({ context: { slot: 1n }, 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));
});
+
+test("snapshot JSON slots never round a v3 bigint beyond safe number precision", async () => {
+ const fullPool = Buffer.alloc(POOL_ACCOUNT_SPACE); poolBytes().copy(fullPool);
+ let slot: unknown = BigInt(Number.MAX_SAFE_INTEGER);
+ const fake = { getMultipleAccountsInfoAndContext: async () => ({ context: { slot }, value: [account(fullPool), null, null, null, null] }) } as unknown as Connection;
+ assert.equal((await fetchPoolSnapshot(fake, programId, a.pool)).slot, Number.MAX_SAFE_INTEGER);
+ for (const invalid of [BigInt(Number.MAX_SAFE_INTEGER) + 1n, -1n, Number.MAX_SAFE_INTEGER + 1]) {
+ slot = invalid;
+ await assert.rejects(fetchPoolSnapshot(fake, programId, a.pool), /precision/);
+ }
+});
diff --git a/app/packages/solana-sdk/tests/instructions.test.ts b/app/packages/solana-sdk/tests/instructions.test.ts
index d8f817a2..31401706 100644
--- a/app/packages/solana-sdk/tests/instructions.test.ts
+++ b/app/packages/solana-sdk/tests/instructions.test.ts
@@ -1,51 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { Keypair, PublicKey, SystemProgram, Transaction } from "@solana/web3.js";
+import { Buffer } from "buffer";
+import { readFileSync } from "node:fs";
+import { Keypair, PublicKey, SystemProgram, Transaction, type Blockhash } from "@solana/web3.js";
import { buildActivateLaunchInstruction, buildBuyInstruction, buildCancelPreparationInstruction, buildClaimFeesInstruction, buildCreateAssociatedTokenInstruction, buildPrepareLaunchInstruction, buildSellInstruction } from "../src/instructions.ts";
import { derivePoolAddresses, programDataAddress, TOKEN_PROGRAM_ID } from "../src/accounts.ts";
import { discriminator, Reader } from "../src/encoding.ts";
import { MAX_TRANSACTION_BYTES } from "../src/transactions.ts";
const programId = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
-const creator = Keypair.fromSeed(new Uint8Array(32).fill(1)).publicKey;
+const creator = (await Keypair.fromSeed(new Uint8Array(32).fill(1))).publicKey;
const base = { creator, nonce: 7n, virtualSol: 30_000_000_000n, feeBps: 0, recipients: [], name: "Example", symbol: "EX", uri: "", contentHash: new Uint8Array(32) };
-function size(...ix: ReturnType[]) { return new Transaction({ feePayer: creator, recentBlockhash: PublicKey.default.toBase58() }).add(...ix).serialize({ requireAllSignatures: false, verifySignatures: false }).length; }
-test("six instruction discriminators, exact args/account order and signer flags", () => {
- const prepare = buildPrepareLaunchInstruction(programId, base);
- assert.ok(prepare.data.subarray(0, 8).equals(discriminator("global", "prepare_launch")));
+async function size(...ix: Awaited>[]) { return (await new Transaction({ feePayer: creator, recentBlockhash: "11111111111111111111111111111111" as Blockhash }).add(...ix).serialize({ requireAllSignatures: false, verifySignatures: false })).length; }
+test("six instruction discriminators, exact args/account order and signer flags", async () => {
+ const prepare = await buildPrepareLaunchInstruction(programId, base);
+ assert.ok(Buffer.from(prepare.data.subarray(0, 8)).equals(discriminator("global", "prepare_launch")));
const reader = new Reader(prepare.data.subarray(8));
assert.equal(reader.long(), 7n); assert.equal(reader.long(), 30_000_000_000n); assert.equal(reader.short(), 0);
assert.equal(reader.text(32), "Example"); assert.equal(reader.text(10), "EX"); assert.equal(reader.text(200, true), "");
assert.ok(prepare.keys[0].isSigner); assert.ok(prepare.keys[1].isSigner); assert.equal(prepare.keys[0].isWritable, false);
- const a = derivePoolAddresses(programId, creator, 7n);
+ const a = await derivePoolAddresses(programId, creator, 7n);
assert.equal(prepare.keys[2].pubkey.toBase58(), a.pool.toBase58());
- const activate = buildActivateLaunchInstruction(programId, creator, 7n);
- assert.equal(activate.keys[8].pubkey.toBase58(), programDataAddress(programId).toBase58());
+ const activate = await buildActivateLaunchInstruction(programId, creator, 7n);
+ assert.equal(activate.keys[8].pubkey.toBase58(), (await programDataAddress(programId)).toBase58());
assert.equal(activate.keys[9].pubkey.toBase58(), TOKEN_PROGRAM_ID.toBase58());
- assert.equal(buildCancelPreparationInstruction(programId, creator, 7n).keys.length, 2);
+ assert.equal((await buildCancelPreparationInstruction(programId, creator, 7n)).keys.length, 2);
for (const build of [buildBuyInstruction, buildSellInstruction]) {
- const ix = build(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 1n, expirySlot: 900n });
+ const ix = await build(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 1n, expirySlot: 900n });
const args = new Reader(ix.data.subarray(8)); assert.equal(args.long(), 10000n); assert.equal(args.long(), 1n); assert.equal(args.long(), 900n);
assert.equal(ix.keys.length, 9); assert.ok(ix.keys[0].isSigner); assert.ok(ix.keys[8].pubkey.equals(SystemProgram.programId));
}
- const claim = buildClaimFeesInstruction(programId, { creator, nonce: 7n, recipients: [{ address: creator, weightBps: 10000, paid: 0n }] }, 0);
+ const claim = await buildClaimFeesInstruction(programId, { creator, nonce: 7n, recipients: [{ address: creator, weightBps: 10000, paid: 0n }] }, 0);
assert.equal(claim.keys.length, 3); assert.ok(claim.keys.every((key) => !key.isSigner)); assert.equal(claim.data[8], 0);
});
-test("maximum preparation, activation and swap+ATA packets fit 1232 bytes independently", (t) => {
+test("maximum preparation, activation and swap+ATA packets fit 1232 bytes independently", async (t) => {
const recipients = Array.from({ length: 7 }, (_, i) => ({ address: new PublicKey(new Uint8Array(32).fill(i + 2)), weightBps: i === 6 ? 1432 : 1428 }));
- const prepare = buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, name: "N".repeat(32), symbol: "S".repeat(10), uri: "https://example.com/" + "x".repeat(180), recipients });
- const activate = buildActivateLaunchInstruction(programId, creator, 7n);
- const a = derivePoolAddresses(programId, creator, 7n);
- const buy = buildBuyInstruction(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 1n, expirySlot: 900n });
- const sizes = { prepare: size(prepare), activate: size(activate), buyWithAta: size(buildCreateAssociatedTokenInstruction(creator, a.mint, creator), buy) };
+ const prepare = await buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, name: "N".repeat(32), symbol: "S".repeat(10), uri: "https://example.com/" + "x".repeat(180), recipients });
+ const activate = await buildActivateLaunchInstruction(programId, creator, 7n);
+ const a = await derivePoolAddresses(programId, creator, 7n);
+ const buy = await buildBuyInstruction(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 1n, expirySlot: 900n });
+ const sizes = { prepare: await size(prepare), activate: await size(activate), buyWithAta: await size(await buildCreateAssociatedTokenInstruction(creator, a.mint, creator), buy) };
for (const value of Object.values(sizes)) assert.ok(value <= MAX_TRANSACTION_BYTES);
t.diagnostic(JSON.stringify(sizes));
});
-test("invalid terms, aliases, UTF-8 bounds and zero slippage minimum reject locally", () => {
- assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, name: "🪙".repeat(9) }));
- assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, name: " " }));
- assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, name: "a\u0085b" }));
- assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, recipients: [{ address: programId, weightBps: 10000 }] }));
- assert.throws(() => buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, recipients: [{ address: derivePoolAddresses(programId, creator, 7n).reserve, weightBps: 10000 }] }));
- assert.throws(() => buildBuyInstruction(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 0n, expirySlot: 900n }));
+test("invalid terms, aliases, UTF-8 bounds and zero slippage minimum reject locally", async () => {
+ await assert.rejects(buildPrepareLaunchInstruction(programId, { ...base, name: "🪙".repeat(9) }));
+ await assert.rejects(buildPrepareLaunchInstruction(programId, { ...base, name: " " }));
+ await assert.rejects(buildPrepareLaunchInstruction(programId, { ...base, name: "a\u0085b" }));
+ await assert.rejects(buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, recipients: [{ address: programId, weightBps: 10000 }] }));
+ await assert.rejects(buildPrepareLaunchInstruction(programId, { ...base, feeBps: 100, recipients: [{ address: (await derivePoolAddresses(programId, creator, 7n)).reserve, weightBps: 10000 }] }));
+ await assert.rejects(buildBuyInstruction(programId, { creator, nonce: 7n, trader: creator, amountIn: 10000n, minimumOut: 0n, expirySlot: 900n }));
+});
+
+test("v3 PDA seeds, account order and instruction bytes match the original ABI fixture exactly", async () => {
+ const fixture = JSON.parse(readFileSync(new URL("./abi-fixture.json", import.meta.url), "utf8"));
+ const fixtureCreator = new PublicKey(fixture.creator);
+ const a = await derivePoolAddresses(programId, fixtureCreator, 7n);
+ assert.deepEqual(Object.fromEntries(Object.entries(a).map(([key, value]) => [key, value instanceof PublicKey ? value.toBase58() : value])), fixture.addresses);
+ const trade = { creator: fixtureCreator, nonce: 7n, trader: fixtureCreator, amountIn: 1_000_000_000n, minimumOut: 10n, expirySlot: 1000n };
+ const instructions = {
+ prepareLaunch: await buildPrepareLaunchInstruction(programId, { ...base, creator: fixtureCreator }),
+ activateLaunch: await buildActivateLaunchInstruction(programId, fixtureCreator, 7n),
+ cancelPreparation: await buildCancelPreparationInstruction(programId, fixtureCreator, 7n),
+ buyExactIn: await buildBuyInstruction(programId, trade),
+ sellExactIn: await buildSellInstruction(programId, trade),
+ claimFees: await buildClaimFeesInstruction(programId, { creator: fixtureCreator, nonce: 7n, recipients: [{ address: fixtureCreator, weightBps: 10000, paid: 0n }] }, 0),
+ };
+ for (const [name, ix] of Object.entries(instructions)) {
+ assert.deepEqual({ dataHex: Buffer.from(ix.data).toString("hex"), keys: ix.keys.map((key) => ({ address: key.pubkey.toBase58(), signer: key.isSigner, writable: key.isWritable })) }, fixture.instructions[name], name);
+ }
});
diff --git a/app/packages/solana-sdk/tests/rpc-compat.test.ts b/app/packages/solana-sdk/tests/rpc-compat.test.ts
new file mode 100644
index 00000000..77fb95b9
--- /dev/null
+++ b/app/packages/solana-sdk/tests/rpc-compat.test.ts
@@ -0,0 +1,123 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Buffer } from "buffer";
+import { Connection, Keypair, PublicKey, SystemProgram, type Blockhash } from "@solana/web3.js";
+import { prepareTransaction, rebroadcastSignedTransaction, reconcileTransaction, simulatePreparedTransaction, submitSignedTransaction, validateSignedTransaction } from "../src/transactions.ts";
+
+type Request = { id: string | number; method: string; params: unknown[] };
+/** Real v3 JSON transport with an in-memory endpoint. No network, wallet or chain transaction. */
+function mockConnection(reply: (request: Request) => Response | unknown) {
+ const requests: Request[] = [];
+ const connection = new Connection("https://solana-rpc.invalid", {
+ commitment: "confirmed", disableRetryOnRateLimit: true,
+ fetch: async (_url, init) => {
+ const request = JSON.parse(String(init?.body)) as Request;
+ requests.push(request);
+ const result = reply(request);
+ return result instanceof Response ? result : Response.json({ jsonrpc: "2.0", id: request.id, result });
+ },
+ });
+ return { connection, requests };
+}
+const blockhash = "11111111111111111111111111111111" as Blockhash;
+const signer = await Keypair.fromSeed(new Uint8Array(32).fill(1));
+const recipient = new PublicKey(new Uint8Array(32).fill(2));
+const instruction = SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 1n });
+const latest = { context: { slot: 500 }, value: { blockhash, lastValidBlockHeight: 100 } };
+
+test("real v3 Connection preserves prepare/simulate/send wire shape and finalized reconciliation", async () => {
+ let status: unknown = null;
+ let statusSlot = 500;
+ let height = 99;
+ let signature = "";
+ const { connection, requests } = mockConnection((request) => {
+ switch (request.method) {
+ case "getLatestBlockhash": return latest;
+ case "simulateTransaction": return { context: { slot: 500 }, value: { err: null, logs: ["simulation ok"], unitsConsumed: 25000 } };
+ case "sendTransaction": return signature;
+ case "getEpochInfo": return { absoluteSlot: 500, blockHeight: height, epoch: 0, slotIndex: 500, slotsInEpoch: 432000, transactionCount: 1 };
+ case "getSignatureStatuses": return { context: { slot: statusSlot }, value: [status] };
+ default: throw new Error(`Unexpected RPC method ${request.method}`);
+ }
+ });
+ const ready = await prepareTransaction(connection, signer.publicKey, [instruction]);
+ assert.equal(ready.lastValidBlockHeight, 100);
+ assert.deepEqual(await simulatePreparedTransaction(connection, ready), { unitsConsumed: 25000, logs: ["simulation ok"] });
+ await ready.transaction.sign(signer);
+ const signedWire = await ready.transaction.serialize({ verifySignatures: false });
+ const signed = await validateSignedTransaction(ready.messageBytes, signedWire, signer.publicKey);
+ assert.deepEqual(signed.bytes, signedWire);
+ signature = signed.signature;
+ assert.deepEqual(await submitSignedTransaction(connection, signed), { signature, status: "submitted" });
+ await rebroadcastSignedTransaction(connection, signed.bytes);
+ const sends = requests.filter((request) => request.method === "sendTransaction");
+ assert.equal(sends.length, 2);
+ assert.deepEqual(sends[0].params, sends[1].params);
+ assert.deepEqual(sends[0].params, [Buffer.from(signedWire).toString("base64"), { encoding: "base64", preflightCommitment: "confirmed" }]);
+ const simulation = requests.find((request) => request.method === "simulateTransaction")!;
+ // v3 omits false-valued flags; both RPC defaults are false (no signature check and no replacement blockhash).
+ assert.deepEqual(simulation.params, [Buffer.from(ready.wireBytes).toString("base64"), { encoding: "base64", commitment: "confirmed" }]);
+ assert.deepEqual(requests[0].params, [{ commitment: "confirmed" }]);
+ assert.deepEqual(await reconcileTransaction(connection, signature, 100), { status: "pending", unseen: true });
+ height = 101; statusSlot = 499;
+ assert.deepEqual(await reconcileTransaction(connection, signature, 100), { status: "pending", unseen: true });
+ statusSlot = 500;
+ assert.deepEqual(await reconcileTransaction(connection, signature, 100), { status: "expired" });
+ status = { slot: 499, confirmations: 1, err: { InstructionError: [0, { Custom: 6010 }] }, confirmationStatus: "confirmed" };
+ assert.equal((await reconcileTransaction(connection, signature, 100)).status, "pending");
+ status = { slot: 499, confirmations: null, err: null, confirmationStatus: "finalized" };
+ assert.equal((await reconcileTransaction(connection, signature, 100)).status, "finalized");
+ assert.deepEqual(requests.find((request) => request.method === "getSignatureStatuses")!.params, [[signature], { searchTransactionHistory: true }]);
+ // Kit removes finalized from the wire because Solana RPC's server default is finalized; proxy must not replace it.
+ assert.deepEqual(requests.find((request) => request.method === "getEpochInfo")!.params, [{}]);
+});
+
+test("real v3 JSON preflight failures, rate limits and ambiguous sends retain their safety meaning", async () => {
+ let mode = "preflight";
+ const { connection } = mockConnection((request) => {
+ if (request.method === "getLatestBlockhash") return latest;
+ assert.equal(request.method, "sendTransaction");
+ if (mode === "timeout") throw new Error("network timeout");
+ if (mode === "429") return new Response("Busy", { status: 429 });
+ if (mode === "503") return new Response("Unavailable", { status: 503 });
+ if (mode === "signature") return "unexpected signature";
+ return Response.json({ jsonrpc: "2.0", id: request.id, error: {
+ code: -32002,
+ message: mode === "already" ? "Transaction simulation failed: AlreadyProcessed" : "Transaction simulation failed: custom program error: 0x177a",
+ data: { err: mode === "already" ? "AlreadyProcessed" : { InstructionError: [0, { Custom: 6010 }] }, logs: mode === "already" || mode === "customNoLogs" ? [] : ["Program log: Error Message: Minimum output not met."] },
+ } });
+ });
+ const ready = await prepareTransaction(connection, signer.publicKey, [instruction]);
+ await ready.transaction.sign(signer);
+ const signed = await validateSignedTransaction(ready.messageBytes, await ready.transaction.serialize({ verifySignatures: false }), signer.publicKey);
+ assert.deepEqual(await submitSignedTransaction(connection, signed), { signature: signed.signature, status: "rejected", error: "Minimum output not met" });
+ mode = "customNoLogs";
+ assert.deepEqual(await submitSignedTransaction(connection, signed), { signature: signed.signature, status: "rejected", error: "Minimum output not met" });
+ for (const next of ["already", "timeout", "503", "signature"]) {
+ mode = next;
+ const result = await submitSignedTransaction(connection, signed);
+ assert.equal(result.status, "unknown", next); assert.equal(result.signature, signed.signature, next);
+ }
+ mode = "429";
+ assert.deepEqual(await submitSignedTransaction(connection, signed), { signature: signed.signature, status: "rejected", error: "Solana RPC is busy" });
+});
+
+test("real v3 JSON transport keeps oversized RPC integers exact until SDK rejects numeric expiry", async () => {
+ const { connection, requests } = mockConnection((request) => {
+ assert.equal(request.method, "getLatestBlockhash");
+ return new Response(`{"jsonrpc":"2.0","id":${JSON.stringify(request.id)},"result":{"context":{"slot":500},"value":{"blockhash":"${blockhash}","lastValidBlockHeight":9007199254740993}}}`, { headers: { "Content-Type": "application/json" } });
+ });
+ assert.equal((await connection.getLatestBlockhash("confirmed")).lastValidBlockHeight, 9007199254740993n);
+ await assert.rejects(prepareTransaction(connection, signer.publicKey, [instruction]), /precision/);
+ assert.ok(requests.every((request) => request.method === "getLatestBlockhash"));
+});
+
+test("real v3 simulation retains custom error codes without bigint serialization failures", async () => {
+ const { connection } = mockConnection((request) => {
+ if (request.method === "getLatestBlockhash") return latest;
+ assert.equal(request.method, "simulateTransaction");
+ return { context: { slot: 500 }, value: { err: { InstructionError: [0, { Custom: 6010 }] }, logs: [], unitsConsumed: 25000 } };
+ });
+ const ready = await prepareTransaction(connection, signer.publicKey, [instruction]);
+ await assert.rejects(simulatePreparedTransaction(connection, ready), { message: "Simulation failed: Minimum output not met" });
+});
diff --git a/app/packages/solana-sdk/tests/transactions.test.ts b/app/packages/solana-sdk/tests/transactions.test.ts
index 159b89ae..bd74ed59 100644
--- a/app/packages/solana-sdk/tests/transactions.test.ts
+++ b/app/packages/solana-sdk/tests/transactions.test.ts
@@ -1,32 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { Keypair, PublicKey, SendTransactionError, SystemProgram, Transaction, type Connection } from "@solana/web3.js";
-import { describeTransactionError, encodeBase58, prepareTransaction, rebroadcastSignedTransaction, reconcileTransaction, submitSignedTransaction, validateSignedTransaction } from "../src/transactions.ts";
+import { Keypair, PublicKey, SendTransactionError, SystemProgram, Transaction, type Blockhash, type Connection } from "@solana/web3.js";
+import { describeTransactionError, encodeBase58, prepareTransaction, rebroadcastSignedTransaction, reconcileTransaction, simulatePreparedTransaction, submitSignedTransaction, validateSignedTransaction, verifyTransactionSignatures } from "../src/transactions.ts";
-const signer = Keypair.fromSeed(new Uint8Array(32).fill(1));
-const recipient = Keypair.fromSeed(new Uint8Array(32).fill(2)).publicKey;
-function transaction() { return new Transaction({ feePayer: signer.publicKey, recentBlockhash: PublicKey.default.toBase58() }).add(SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 1n })); }
-test("signatures derived locally; changed message, partial signatures, invalid signatures refused", () => {
- const tx = transaction(); const expected = tx.serializeMessage(); tx.sign(signer);
- const validated = validateSignedTransaction(expected, tx.serialize(), signer.publicKey);
+const signer = await Keypair.fromSeed(new Uint8Array(32).fill(1));
+const recipient = (await Keypair.fromSeed(new Uint8Array(32).fill(2))).publicKey;
+function transaction() { return new Transaction({ feePayer: signer.publicKey, recentBlockhash: "11111111111111111111111111111111" as Blockhash }).add(SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 1n })); }
+test("signatures derived locally; changed message, partial signatures, invalid signatures refused", async () => {
+ const tx = transaction(); const expected = tx.serializeMessage(); await tx.sign(signer);
+ const validated = await validateSignedTransaction(expected, await tx.serialize({ verifySignatures: false }), signer.publicKey);
assert.equal(validated.signature, encodeBase58(tx.signature!));
- const changed = transaction().add(SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 100n })); changed.sign(signer);
- assert.throws(() => validateSignedTransaction(expected, changed.serialize(), signer.publicKey), /changed/);
- assert.throws(() => validateSignedTransaction(expected, transaction().serialize({ requireAllSignatures: false, verifySignatures: false }), signer.publicKey), /signatures/);
- const damaged = tx.serialize(); damaged[2] ^= 1;
- assert.throws(() => validateSignedTransaction(expected, damaged, signer.publicKey));
+ const changed = transaction().add(SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: recipient, lamports: 100n })); await changed.sign(signer);
+ await assert.rejects(validateSignedTransaction(expected, await changed.serialize({ verifySignatures: false }), signer.publicKey), /changed/);
+ await assert.rejects(validateSignedTransaction(expected, await transaction().serialize({ requireAllSignatures: false, verifySignatures: false }), signer.publicKey), /signatures/);
+ const damaged = await tx.serialize({ verifySignatures: false }); damaged[2] ^= 1;
+ await assert.rejects(validateSignedTransaction(expected, damaged, signer.publicKey), /signatures/);
+ await assert.rejects(validateSignedTransaction(expected, await tx.serialize({ verifySignatures: false }), recipient), /changed/);
+ await assert.rejects(validateSignedTransaction(expected, new Uint8Array(1233), signer.publicKey), /size/);
+ const trailing = Uint8Array.of(...await tx.serialize({ verifySignatures: false }), 0);
+ await assert.rejects(validateSignedTransaction(expected, trailing, signer.publicKey), /encoding/);
assert.equal(encodeBase58(new Uint8Array(32)), PublicKey.default.toBase58());
});
test("ambiguous submission retains local signature and never creates a replacement", async () => {
- const tx = transaction(); const expected = tx.serializeMessage(); tx.sign(signer);
- const signed = validateSignedTransaction(expected, tx.serialize(), signer.publicKey);
+ const tx = transaction(); const expected = tx.serializeMessage(); await tx.sign(signer);
+ const signed = await validateSignedTransaction(expected, await tx.serialize({ verifySignatures: false }), signer.publicKey);
const fake = { sendRawTransaction: async () => { throw new Error("network timeout"); } } as unknown as Connection;
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);
+ const tx = transaction(); const expected = tx.serializeMessage(); await tx.sign(signer);
+ const signed = await validateSignedTransaction(expected, await tx.serialize({ verifySignatures: false }), 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;
@@ -47,9 +51,46 @@ test("a send refused before forwarding is rejected with the program's reason; tr
thrown = new Error("network timeout");
await rebroadcastSignedTransaction(fake, signed.bytes);
});
+
+test("strict verifier checks every signer and rejects small-order keys and signature R points", async () => {
+ const second = await Keypair.fromSeed(new Uint8Array(32).fill(3));
+ const multi = transaction().add(SystemProgram.transfer({ fromPubkey: second.publicKey, toPubkey: recipient, lamports: 1n }));
+ await multi.partialSign(signer);
+ assert.equal(await verifyTransactionSignatures(multi), false);
+ await multi.partialSign(second);
+ assert.equal(await verifyTransactionSignatures(multi), true);
+ multi.signatures[1].signature![1] ^= 1;
+ assert.equal(await verifyTransactionSignatures(multi), false);
+
+ // Ed25519 compressed identity point (y=1), a small-order public key and signature R; no private key exists here.
+ // Kit 8.5 explicitly rejects these instead of inheriting Node WebCrypto's permissive verification semantics.
+ const identity = Uint8Array.of(1, ...new Uint8Array(31));
+ const forgedPayer = new PublicKey(identity);
+ const forged = new Transaction({ feePayer: forgedPayer, recentBlockhash: "11111111111111111111111111111111" as Blockhash })
+ .add(SystemProgram.transfer({ fromPubkey: forgedPayer, toPubkey: recipient, lamports: 1n }));
+ forged.addSignature(forgedPayer, Uint8Array.of(...identity, ...new Uint8Array(32)));
+ assert.equal(await verifyTransactionSignatures(forged), false);
+ await assert.rejects(validateSignedTransaction(forged.serializeMessage(), await forged.serialize({ verifySignatures: false }), forgedPayer), /signatures/);
+
+ const valid = transaction(); await valid.sign(signer);
+ valid.signatures[0].signature!.set(identity, 0);
+ assert.equal(await verifyTransactionSignatures(valid), false);
+});
+
+test("wallet-owned arrays cannot change the transaction while async verification is pending", async () => {
+ const tx = transaction(); await tx.sign(signer);
+ const bytes = await tx.serialize({ verifySignatures: false });
+ const expected = tx.serializeMessage();
+ const original = Uint8Array.from(bytes);
+ const validation = validateSignedTransaction(expected, bytes, signer.publicKey);
+ bytes.fill(0); expected.fill(0);
+ assert.deepEqual((await validation).bytes, original);
+});
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: [0n, { Custom: 6010n }] }), "Minimum output not met");
+ assert.equal(describeTransactionError("Custom program error: #6010 (instruction #1)"), "Minimum output not met");
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");
@@ -61,18 +102,18 @@ test("transaction errors read as the program's messages", () => {
assert(performance.now() - started < 50);
});
test("reconciliation needs finalized expiry or finalized failure before safe replacement", async () => {
- let height = 99;
+ let height = 99n;
let value: unknown = null;
- let statusSlot = 500;
+ let statusSlot = 500n;
const fake = {
- getEpochInfo: async () => ({ absoluteSlot: 400, blockHeight: height, epoch: 0, slotIndex: 0, slotsInEpoch: 1 }),
+ getEpochInfo: async () => ({ absoluteSlot: 400n, blockHeight: height, epoch: 0n, slotIndex: 0n, slotsInEpoch: 1n }),
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");
+ height = 101n; 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");
+ statusSlot = 399n; assert.deepEqual(await reconcileTransaction(fake, "test", 100), { status: "pending", unseen: true });
+ statusSlot = 400n; 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" };
@@ -83,9 +124,39 @@ test("reconciliation needs finalized expiry or finalized failure before safe rep
assert.equal((await reconcileTransaction(fake, "test", 100)).status, "finalized");
});
test("preparation reports exact unsigned bytes and enforces packet size", async () => {
- const fake = { getLatestBlockhash: async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: 100 }) } as unknown as Connection;
+ const fake = { getLatestBlockhash: async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: 100n }) } as unknown as Connection;
const ready = await prepareTransaction(fake, signer.publicKey, transaction().instructions);
assert.equal(ready.lastValidBlockHeight, 100);
assert.deepEqual(ready.messageBytes, ready.transaction.serializeMessage());
await assert.rejects(prepareTransaction(fake, signer.publicKey, []), /no instructions/);
});
+
+test("RPC bigint boundaries stay exact and unsafe expiry is rejected before signing or broadcasting", async () => {
+ let expiry: unknown = BigInt(Number.MAX_SAFE_INTEGER);
+ let broadcasts = 0;
+ const fake = {
+ getLatestBlockhash: async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: expiry }),
+ sendRawTransaction: async () => { broadcasts++; throw new Error("Unexpected broadcast"); },
+ } as unknown as Connection;
+ assert.equal((await prepareTransaction(fake, signer.publicKey, transaction().instructions)).lastValidBlockHeight, Number.MAX_SAFE_INTEGER);
+ for (const invalid of [BigInt(Number.MAX_SAFE_INTEGER) + 1n, -1n, 0n, Number.MAX_SAFE_INTEGER + 1]) {
+ expiry = invalid;
+ await assert.rejects(prepareTransaction(fake, signer.publicKey, transaction().instructions), /expiry|precision/);
+ }
+ assert.equal(broadcasts, 0);
+ await assert.rejects(reconcileTransaction(fake, "test", Number.MAX_SAFE_INTEGER + 1), /expiry/);
+});
+
+test("simulation awaits v3 serialization and converts units without rounding", async () => {
+ let units = 25000n;
+ let options: unknown;
+ const fake = {
+ getLatestBlockhash: async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: 100n }),
+ simulateTransaction: async (_tx: unknown, config: unknown) => { options = config; return { value: { err: null, logs: ["ok"], unitsConsumed: units } }; },
+ } as unknown as Connection;
+ const ready = await prepareTransaction(fake, signer.publicKey, transaction().instructions);
+ assert.deepEqual(await simulatePreparedTransaction(fake, ready), { unitsConsumed: 25000, logs: ["ok"] });
+ assert.deepEqual(options, { sigVerify: false, replaceRecentBlockhash: false, commitment: "confirmed" });
+ units = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
+ await assert.rejects(simulatePreparedTransaction(fake, ready), /precision/);
+});
diff --git a/app/packages/solana-sdk/tests/verification.test.ts b/app/packages/solana-sdk/tests/verification.test.ts
index 651afab6..1b079922 100644
--- a/app/packages/solana-sdk/tests/verification.test.ts
+++ b/app/packages/solana-sdk/tests/verification.test.ts
@@ -6,18 +6,18 @@ import { PublicKey } from "@solana/web3.js";
import { programDataAddress } from "../src/accounts.ts";
import { CLUSTER_GENESIS, validateDeploymentManifest, verifyDeploymentBytes } from "../src/verification.ts";
-test("release identity must be explicit, not compile fixture or unreviewed defaults", () => {
+test("release identity must be explicit, not compile fixture or unreviewed defaults", async () => {
const programId = new PublicKey(new Uint8Array(32).fill(7));
- 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() }));
+ const manifest = { schema: 1, cluster: "devnet", programId: programId.toBase58(), programData: (await 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((await validateDeploymentManifest(manifest)).releaseApproved, false);
+ await assert.rejects(validateDeploymentManifest({ ...manifest, releaseApproved: true }), /review evidence/);
+ await assert.rejects(validateDeploymentManifest({ ...manifest, programId: "Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr" }), /fixture/);
+ await assert.rejects(validateDeploymentManifest({ ...manifest, genesisHash: null }));
+ await assert.rejects(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");
+ await assert.rejects(validateDeploymentManifest({ ...manifest, cluster: "mainnet-beta" }), /declared cluster/);
+ await assert.rejects(validateDeploymentManifest({ ...manifest, genesisHash: new PublicKey(new Uint8Array(32).fill(8)).toBase58() }), /declared cluster/);
+ assert.equal((await 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/scripts/solana-checks.mjs b/app/scripts/solana-checks.mjs
new file mode 100644
index 00000000..7b6fd169
--- /dev/null
+++ b/app/scripts/solana-checks.mjs
@@ -0,0 +1,87 @@
+// Build/test only. No RPC, wallet input, deployment, signing or authority operations.
+import { spawnSync } from "node:child_process";
+import { statSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SOLANA_DIR = fileURLToPath(new URL("../../solana/", import.meta.url));
+export const SOLANA_CHECK_TIMEOUT_MS = 35 * 60 * 1000;
+
+const CHECKS = [
+ ["Rust toolchain", "rustup", ["toolchain", "install", "1.97.1", "--profile", "minimal", "--component", "rustfmt", "--component", "clippy"]],
+ ["Rust formatting", "cargo", ["+1.97.1", "fmt", "--all", "--check"]],
+ ["Rust host tests", "cargo", ["+1.97.1", "test", "-p", "launch-pool", "--locked"]],
+ ["Pinned SBF build tool", "cargo", ["+1.97.1", "install", "cargo-build-sbf", "--version", "4.2.0", "--locked", "--root", ".tools"]],
+ ["SBFv3 build and diagnostics", "bash", ["scripts/check-sbf.sh"]],
+ ["Compiled-program runtime tests", "cargo", ["+1.97.1", "test", "--workspace", "--features", "svm-tests", "--locked"]],
+ ["Rust Clippy", "cargo", ["+1.97.1", "clippy", "--workspace", "--all-targets", "--features", "svm-tests", "--locked", "--", "-D", "warnings"]],
+];
+
+/** Execute the same required checks locally and from the existing app CI job.
+ * Dependencies are injectable for regression tests; the CLI always uses real tools.
+ */
+export function runSolanaChecks({
+ args = process.argv.slice(2),
+ env = process.env,
+ platform = process.platform,
+ spawn = spawnSync,
+ stat = statSync,
+ now = Date.now,
+ log = console.log,
+} = {}) {
+ if (args.length > 1 || (args.length === 1 && args[0] !== "--ci")) {
+ throw new Error("Usage: node scripts/solana-checks.mjs [--ci]");
+ }
+ if (args[0] === "--ci" && env.CI !== "true" && env.GITHUB_ACTIONS !== "true") {
+ log("Solana Rust/SBF checks skipped outside CI. Run npm run test:solana-runtime explicitly; see solana/BUILD.md for prerequisites.");
+ return { status: "skipped", checks: 0 };
+ }
+ if (platform === "win32") {
+ throw new Error("The combined Solana runner requires Linux or macOS with rustup, cargo and Bash on PATH. Use WSL, or the explicit Windows build/runtime commands in solana/BUILD.md; no Git Bash path is guessed.");
+ }
+ for (const source of ["Cargo.toml", "Cargo.lock", "programs/launch_pool/Cargo.toml", "scripts/check-sbf.sh"]) {
+ let present = false;
+ try { present = stat(join(SOLANA_DIR, source)).isFile(); } catch { /* Fail closed below. */ }
+ if (!present) throw new Error(`Solana checks require the full repository checkout: missing ${source}.`);
+ }
+
+ const deadline = now() + SOLANA_CHECK_TIMEOUT_MS;
+ const childEnv = { ...env, SBF_BUILD_BIN: join(SOLANA_DIR, ".tools", "bin", "cargo-build-sbf") };
+ for (const [label, command, commandArgs] of CHECKS) {
+ const remaining = deadline - now();
+ if (remaining <= 0) throw new Error("Solana checks exceeded the 35-minute budget.");
+ log(`Solana checks: ${label}`);
+ const result = spawn(command, commandArgs, {
+ cwd: SOLANA_DIR,
+ env: childEnv,
+ stdio: "inherit",
+ shell: false,
+ windowsHide: true,
+ timeout: remaining,
+ killSignal: "SIGKILL",
+ });
+ if (result.error) throw new Error(`${label} could not complete (${result.error.code ?? "spawn error"}). Required tools/checks are never skipped.`);
+ if (result.signal || result.status !== 0) {
+ throw new Error(`${label} failed (${result.signal ?? `exit ${result.status ?? "unknown"}`}).`);
+ }
+ if (now() >= deadline) throw new Error("Solana checks exceeded the 35-minute budget.");
+ if (command === "bash") {
+ let built = false;
+ try {
+ const elf = stat(join(SOLANA_DIR, "target", "deploy", "launch_pool.so"));
+ built = elf.isFile() && elf.size > 0;
+ } catch { /* Fail closed below. */ }
+ if (!built) throw new Error("SBF build did not produce a nonempty launch_pool.so; runtime tests cannot use missing bytecode.");
+ }
+ }
+ log("Solana host, SBFv3 and offline runtime checks passed. This is not deployment or release approval.");
+ return { status: "passed", checks: CHECKS.length };
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try { runSolanaChecks(); }
+ catch (error) {
+ console.error(error instanceof Error ? error.message : "Solana checks failed.");
+ process.exitCode = 1;
+ }
+}
diff --git a/app/src/app/api/solana/rpc/route.ts b/app/src/app/api/solana/rpc/route.ts
index 7fc554a6..b8417c93 100644
--- a/app/src/app/api/solana/rpc/route.ts
+++ b/app/src/app/api/solana/rpc/route.ts
@@ -1,6 +1,6 @@
import { configuredSolana, solanaRpcUrl } from "@/lib/solana/server";
import { allowedSolanaRpc } from "@/lib/solana/rpc-policy";
-import { boundedJson } from "@/lib/solana/bounded-json";
+import { boundedJson, boundedText } 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";
@@ -69,7 +69,8 @@ export async function POST(req: Request) {
{ status: 400, headers },
);
}
- if (!allowedSolanaRpc(body, config.programId))
+ const rpc = await allowedSolanaRpc(body, config.programId);
+ if (!rpc)
return Response.json(
{
jsonrpc: "2.0",
@@ -78,7 +79,7 @@ export async function POST(req: Request) {
},
{ headers },
);
- const release = (SIGNING.has(body.method) ? signing : reads)();
+ const release = (SIGNING.has(rpc.method) ? signing : reads)();
if (!release) return busy();
try {
if (
@@ -118,7 +119,7 @@ export async function POST(req: Request) {
const response = await fetch(upstream, {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
+ body: JSON.stringify(rpc),
signal: AbortSignal.timeout(15_000),
cache: "no-store",
});
@@ -128,7 +129,10 @@ export async function POST(req: Request) {
return busy();
}
if (!response.ok) throw new Error("Upstream unavailable");
- const result = (await boundedJson(response.body, 4 * 1024 * 1024)) as {
+ // Inspect the envelope, but forward the original JSON: parsing and reserializing would round
+ // u64 RPC balances before web3.js v3's lossless parser can turn them into bigint.
+ const raw = await boundedText(response.body, 4 * 1024 * 1024);
+ const result = JSON.parse(raw) as {
jsonrpc?: unknown;
id?: unknown;
result?: unknown;
@@ -136,27 +140,31 @@ export async function POST(req: Request) {
};
if (
!result ||
+ typeof result !== "object" ||
+ Array.isArray(result) ||
result.jsonrpc !== "2.0" ||
- result.id !== body.id ||
- (!("result" in result) && !("error" in result))
+ result.id !== rpc.id ||
+ (("result" in result) === ("error" in result))
)
throw new Error("Invalid RPC response");
- if (result.error)
+ if ("error" in result)
return Response.json(
{
jsonrpc: "2.0",
- id: body.id,
- error: solanaRpcError(result.error, body.method),
+ id: rpc.id,
+ error: solanaRpcError(result.error, rpc.method),
},
{ headers },
);
- return Response.json(result, { headers });
+ return new Response(raw, {
+ headers: { ...headers, "content-type": "application/json" },
+ });
} catch {
network = null;
return Response.json(
{
jsonrpc: "2.0",
- id: body.id,
+ id: rpc.id,
error: {
code: -32000,
message:
diff --git a/app/src/components/solana/LaunchPanel.tsx b/app/src/components/solana/LaunchPanel.tsx
index cfb7a0c1..9e7919b7 100644
--- a/app/src/components/solana/LaunchPanel.tsx
+++ b/app/src/components/solana/LaunchPanel.tsx
@@ -77,7 +77,7 @@ export default function LaunchPanel({
weightBps: Number(weight),
};
});
- const ix = buildPrepareLaunchInstruction(program, {
+ const ix = await buildPrepareLaunchInstruction(program, {
creator,
nonce,
virtualSol,
@@ -88,7 +88,7 @@ export default function LaunchPanel({
contentHash,
recipients: beneficiaries,
});
- const addresses = derivePoolAddresses(program, creator, nonce);
+ const addresses = await derivePoolAddresses(program, creator, nonce);
const pool = addresses.pool.toBase58();
// Pool::SPACE is ABI-v1 fixed maximum allocation, even with shorter text.
const rent = requireRent(
@@ -119,7 +119,7 @@ export default function LaunchPanel({
: []),
[
"Preparation rent (estimated, permanent)",
- `${formatUnits(BigInt(rent), 9)} SOL`,
+ `${formatUnits(rent, 9)} SOL`,
],
],
pool,
diff --git a/app/src/components/solana/PoolPanel.tsx b/app/src/components/solana/PoolPanel.tsx
index ff4fc7ac..0421a99c 100644
--- a/app/src/components/solana/PoolPanel.tsx
+++ b/app/src/components/solana/PoolPanel.tsx
@@ -25,6 +25,7 @@ import {
explorerUrl,
formatUnits,
parseUnits,
+ requireLamports,
requireRent,
} from "@/lib/solana/config";
import type { SolanaPanelProps } from "./SolanaWorkspace";
@@ -84,7 +85,7 @@ export default function PoolPanel({
owner
? [
owner,
- deriveAssociatedTokenAddress(snapshot.addresses.mint, owner),
+ await deriveAssociatedTokenAddress(snapshot.addresses.mint, owner),
]
: [],
);
@@ -94,11 +95,7 @@ export default function PoolPanel({
setQuoteError("");
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.",
- );
+ const sol = requireLamports(wallet?.lamports ?? 0n);
if (
token &&
(!token.owner.equals(TOKEN_PROGRAM_ID) ||
@@ -113,8 +110,14 @@ export default function PoolPanel({
setBalance({
wallet: address,
mint: result.addresses.mint.toBase58(),
- sol: BigInt(sol),
- tokens: token?.data.readBigUInt64LE(64) ?? 0n,
+ sol,
+ tokens: token
+ ? new DataView(
+ token.data.buffer,
+ token.data.byteOffset,
+ token.data.byteLength,
+ ).getBigUint64(64, true)
+ : 0n,
});
} else if (alive) setBalance(null);
} catch {
@@ -159,7 +162,7 @@ export default function PoolPanel({
snapshot: current,
extra: [ata],
} = await readPoolSnapshot(connection, program, snapshot.address, [
- deriveAssociatedTokenAddress(snapshot.addresses.mint, trader),
+ await deriveAssociatedTokenAddress(snapshot.addresses.mint, trader),
]);
if (current.pool.status !== "active")
throw new Error("This pool is not active.");
@@ -180,19 +183,19 @@ export default function PoolPanel({
};
const instructions =
side === "buy"
- ? [
+ ? await Promise.all([
buildCreateAssociatedTokenInstruction(
trader,
current.addresses.mint,
trader,
),
buildBuyInstruction(program, args),
- ]
- : [buildSellInstruction(program, args)];
+ ])
+ : [await buildSellInstruction(program, args)];
const extraRent =
side === "buy" && !ata
? requireRent(await connection.getMinimumBalanceForRentExemption(165))
- : 0;
+ : 0n;
await actions.prepare(
side === "buy" ? "Buy tokens" : "Sell tokens",
instructions,
@@ -213,7 +216,7 @@ export default function PoolPanel({
["Trading fee (included)", `${formatUnits(quote.fee, 9)} SOL`],
[
"Token account rent (extra, estimated)",
- `${formatUnits(BigInt(extraRent), 9)} SOL`,
+ `${formatUnits(extraRent, 9)} SOL`,
],
["Expiry slot", expirySlot.toString()],
],
@@ -254,14 +257,14 @@ export default function PoolPanel({
),
),
)
- ).reduce((sum, n) => sum + n, 0)
- : 0;
+ ).reduce((sum, n) => sum + n, 0n)
+ : 0n;
await actions.prepare(
activate ? "Activate launch" : "Cancel preparation",
[
activate
- ? buildActivateLaunchInstruction(program, creator, pool.nonce)
- : buildCancelPreparationInstruction(program, creator, pool.nonce),
+ ? await buildActivateLaunchInstruction(program, creator, pool.nonce)
+ : await buildCancelPreparationInstruction(program, creator, pool.nonce),
],
[
["Token", `${pool.name} (${pool.symbol})`],
@@ -274,7 +277,7 @@ export default function PoolPanel({
],
[
"Additional account rent (estimated)",
- `${formatUnits(BigInt(rent), 9)} SOL`,
+ `${formatUnits(rent, 9)} SOL`,
],
["Initial real SOL", "0 SOL"],
["Fixed trading fee", `${pool.feeBps / 100}%`],
@@ -308,7 +311,7 @@ export default function PoolPanel({
throw new Error("There are no fees to claim for this recipient.");
await actions.prepare(
"Claim fees",
- [buildClaimFeesInstruction(program, current.pool, index)],
+ [await buildClaimFeesInstruction(program, current.pool, index)],
[
["Token mint", current.addresses.mint.toBase58()],
["Recipient (fixed)", recipient.address.toBase58()],
diff --git a/app/src/components/solana/SolanaWorkspace.tsx b/app/src/components/solana/SolanaWorkspace.tsx
index 1b134527..2bd7c953 100644
--- a/app/src/components/solana/SolanaWorkspace.tsx
+++ b/app/src/components/solana/SolanaWorkspace.tsx
@@ -350,7 +350,7 @@ export default function SolanaWorkspace({
...actions.review.lines,
[
"Network fee (estimated)",
- `${formatUnits(BigInt(actions.review.networkFee), 9)} SOL`,
+ `${formatUnits(actions.review.networkFee, 9)} SOL`,
],
].map(([term, value]) => (
{
assert.deepEqual(await boundedJson(new Response('{"result":1}').body, 12), {
@@ -24,3 +26,36 @@ test("bounded JSON rejects missing and malformed input", async () => {
await assert.rejects(boundedJson(null, 10));
await assert.rejects(boundedJson(new Response("invalid").body, 10));
});
+
+test("bounded raw RPC forwarding preserves u64 balances for the v3 lossless parser", async () => {
+ const key = new PublicKey(new Uint8Array(32).fill(7));
+ const values = [9007199254740993n, 18446744073709551615n];
+ for (const value of values) {
+ const connection = new Connection("https://offline.invalid", {
+ commitment: "confirmed",
+ fetch: async (_input, init) => {
+ const request = JSON.parse(String(init?.body));
+ assert.equal(request.method, "getBalance");
+ assert.notEqual(await allowedSolanaRpc(request, key.toBase58()), null);
+ // Same bounded text/envelope-validation/raw forwarding used by the proxy. No network request.
+ const upstream = new Response(`{"jsonrpc":"2.0","id":${JSON.stringify(request.id)},"result":{"context":{"slot":50},"value":${value}}}`);
+ const raw = await boundedText(upstream.body, 4 * 1024 * 1024);
+ const envelope = JSON.parse(raw);
+ assert.equal(envelope.id, request.id);
+ assert.equal(envelope.jsonrpc, "2.0");
+ return new Response(raw, { headers: { "content-type": "application/json" } });
+ },
+ });
+ assert.equal(await connection.getBalance(key, { commitment: "confirmed", minContextSlot: 50n }), value);
+ }
+});
+
+test("bounded text keeps the same byte limit and cancels oversized upstream streams", async () => {
+ let cancelled = false;
+ const body = new ReadableStream({
+ start(controller) { controller.enqueue(new TextEncoder().encode("12345")); },
+ cancel() { cancelled = true; },
+ });
+ await assert.rejects(boundedText(body, 4), /size limit/);
+ assert.equal(cancelled, true);
+});
diff --git a/app/src/lib/solana/bounded-json.ts b/app/src/lib/solana/bounded-json.ts
index 89a679a8..8c1b685e 100644
--- a/app/src/lib/solana/bounded-json.ts
+++ b/app/src/lib/solana/bounded-json.ts
@@ -1,8 +1,8 @@
/** Bound streamed input as well as Content-Length; never trust an upstream length header. */
-export async function boundedJson(
+export async function boundedText(
body: ReadableStream | null,
maximum: number,
-): Promise {
+): Promise {
if (!body) throw new Error("Missing response body");
const reader = body.getReader();
const parts: Uint8Array[] = [];
@@ -21,5 +21,12 @@ export async function boundedJson(
} finally {
reader.releaseLock();
}
- return JSON.parse(Buffer.concat(parts).toString("utf8"));
+ return Buffer.concat(parts).toString("utf8");
+}
+
+export async function boundedJson(
+ body: ReadableStream | null,
+ maximum: number,
+): Promise {
+ return JSON.parse(await boundedText(body, maximum));
}
diff --git a/app/src/lib/solana/ci-checks.test.ts b/app/src/lib/solana/ci-checks.test.ts
new file mode 100644
index 00000000..72f69680
--- /dev/null
+++ b/app/src/lib/solana/ci-checks.test.ts
@@ -0,0 +1,136 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { test } from "node:test";
+import { runSolanaChecks, SOLANA_CHECK_TIMEOUT_MS } from "../../../scripts/solana-checks.mjs";
+
+const runner = new URL("../../../scripts/solana-checks.mjs", import.meta.url);
+const solanaDir = fileURLToPath(new URL("../../../../solana/", import.meta.url));
+const expected = [
+ ["rustup", ["toolchain", "install", "1.97.1", "--profile", "minimal", "--component", "rustfmt", "--component", "clippy"]],
+ ["cargo", ["+1.97.1", "fmt", "--all", "--check"]],
+ ["cargo", ["+1.97.1", "test", "-p", "launch-pool", "--locked"]],
+ ["cargo", ["+1.97.1", "install", "cargo-build-sbf", "--version", "4.2.0", "--locked", "--root", ".tools"]],
+ ["bash", ["scripts/check-sbf.sh"]],
+ ["cargo", ["+1.97.1", "test", "--workspace", "--features", "svm-tests", "--locked"]],
+ ["cargo", ["+1.97.1", "clippy", "--workspace", "--all-targets", "--features", "svm-tests", "--locked", "--", "-D", "warnings"]],
+];
+
+function harness(overrides: Record = {}) {
+ const calls: { command: string; args: string[]; options: Record }[] = [];
+ const logs: string[] = [];
+ const options = {
+ args: [], env: {}, platform: "linux", now: () => 0,
+ stat: () => ({ isFile: () => true, size: 1 }),
+ log: (line: string) => logs.push(line),
+ spawn: (command: string, args: string[], options: Record) => {
+ calls.push({ command, args, options });
+ return { status: 0, signal: null };
+ },
+ ...overrides,
+ };
+ return { calls, logs, run: () => runSolanaChecks(options) };
+}
+
+test("Solana runner imports without executing tools, including in CI", () => {
+ const imported = spawnSync(process.execPath, ["--input-type=module", "-e", `await import(${JSON.stringify(runner.href)}); console.log('imported');`], {
+ env: { ...process.env, CI: "true", GITHUB_ACTIONS: "true", PATH: "" },
+ encoding: "utf8", timeout: 5_000, windowsHide: true,
+ });
+ assert.equal(imported.status, 0, imported.stderr);
+ assert.equal(imported.stdout.trim(), "imported");
+});
+
+test("explicit runtime invocation preserves every pinned check, sequence and working directory", () => {
+ const h = harness();
+ assert.deepEqual(h.run(), { status: "passed", checks: 7 });
+ assert.deepEqual(h.calls.map(({ command, args }) => [command, args]), expected);
+ for (const { options } of h.calls) {
+ assert.equal(options.cwd, solanaDir);
+ assert.equal(options.shell, false);
+ assert.equal(options.windowsHide, true);
+ assert.equal(options.stdio, "inherit");
+ assert.equal(options.timeout, SOLANA_CHECK_TIMEOUT_MS);
+ assert.equal(options.killSignal, "SIGKILL");
+ assert.equal((options.env as Record).SBF_BUILD_BIN, join(solanaDir, ".tools", "bin", "cargo-build-sbf"));
+ }
+});
+
+test("both generic CI and GitHub Actions execute checks, even when the other flag is false", () => {
+ for (const env of [{ CI: "true" }, { GITHUB_ACTIONS: "true" }, { CI: "false", GITHUB_ACTIONS: "true" }]) {
+ assert.equal(harness({ args: ["--ci"], env }).run().status, "passed");
+ }
+});
+
+test("ordinary local npm test skips explicitly without probing or executing tools", () => {
+ const h = harness({ args: ["--ci"], stat: () => { throw new Error("must not probe"); } });
+ assert.deepEqual(h.run(), { status: "skipped", checks: 0 });
+ assert.equal(h.calls.length, 0);
+ assert.match(h.logs.join("\n"), /skipped outside CI.*test:solana-runtime/);
+});
+
+test("unknown flags and ambiguous Windows execution fail instead of skipping required checks", () => {
+ for (const args of [["--skip"], ["--ci", "--force"]]) assert.throws(() => harness({ args }).run(), /Usage:/);
+ assert.throws(() => harness({ platform: "win32" }).run(), /Use WSL.*Windows build/);
+ assert.throws(() => harness({ platform: "win32", args: ["--ci"], env: { CI: "true" } }).run(), /Use WSL/);
+});
+
+test("a partial checkout fails before running commands", () => {
+ const h = harness({ stat: () => { throw new Error("ENOENT"); } });
+ assert.throws(h.run, /full repository checkout/);
+ assert.equal(h.calls.length, 0);
+});
+
+test("every nonzero, missing-tool, signal and null-status failure stops subsequent checks", () => {
+ for (let failure = 0; failure < expected.length; failure++) {
+ for (const failed of [
+ { status: 1, signal: null },
+ { status: null, signal: null, error: { code: "ENOENT" } },
+ { status: null, signal: "SIGTERM" },
+ { status: null, signal: null },
+ { status: null, signal: "SIGKILL", error: { code: "ETIMEDOUT" } },
+ ]) {
+ let calls = 0;
+ const h = harness({ spawn: () => calls++ === failure ? failed : { status: 0, signal: null } });
+ assert.throws(h.run, /failed|could not complete/);
+ assert.equal(calls, failure + 1);
+ }
+ }
+});
+
+test("runtime tests never run after a missing or empty ELF", () => {
+ for (const size of [0, -1]) {
+ const h = harness({ stat: (path: string) => ({ isFile: () => true, size: path.endsWith(".so") ? size : 1 }) });
+ assert.throws(h.run, /nonempty launch_pool.so/);
+ assert.equal(h.calls.length, 5);
+ }
+ const missing = harness({ stat: (path: string) => {
+ if (path.endsWith(".so")) throw new Error("ENOENT");
+ return { isFile: () => true, size: 1 };
+ } });
+ assert.throws(missing.run, /nonempty launch_pool.so/);
+ assert.equal(missing.calls.length, 5);
+});
+
+test("all commands share the original 35-minute budget", () => {
+ let tick = 0;
+ const h = harness({ now: () => tick++ * 1_000 });
+ h.run();
+ assert.equal(h.calls[0].options.timeout, SOLANA_CHECK_TIMEOUT_MS - 1_000);
+ assert.ok(Number(h.calls[6].options.timeout) < Number(h.calls[0].options.timeout));
+ let calls = 0;
+ const expired = harness({ now: () => calls++ === 0 ? 0 : SOLANA_CHECK_TIMEOUT_MS });
+ assert.throws(expired.run, /35-minute budget/);
+ assert.equal(expired.calls.length, 0);
+});
+
+test("existing npm test retains SDK/model and CI runtime coverage with an explicit local entry point", () => {
+ const manifest = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8"));
+ assert.match(manifest.scripts.test, /npm run test:solana\s*&&\s*node scripts\/solana-checks\.mjs --ci/);
+ assert.equal(manifest.scripts["test:solana-runtime"], "node scripts/solana-checks.mjs");
+ assert.match(manifest.scripts["test:solana"], /tsc --noEmit -p packages\/solana-sdk\/tsconfig.json/);
+ assert.match(manifest.scripts["test:solana"], /tsx --test .*packages\/solana-sdk\/tests/);
+ assert.match(manifest.scripts["test:solana"], /node --experimental-strip-types --test .*packages\/solana-model/);
+});
diff --git a/app/src/lib/solana/client-migration.test.ts b/app/src/lib/solana/client-migration.test.ts
new file mode 100644
index 00000000..426d3b82
--- /dev/null
+++ b/app/src/lib/solana/client-migration.test.ts
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { Keypair, PublicKey, SystemProgram, Transaction } from "@solana/web3.js";
+import { validateSignedTransaction } from "@openlaunch/solana-sdk";
+import { journalKey, persistJournalForSession, readJournal, type PendingSolanaAction } from "./journal.ts";
+import { validWalletAccount } from "./wallet.ts";
+
+test("v3 wallet validation preserves account bytes and the existing JSON recovery record", async () => {
+ // Disposable local fixture, never connected to an RPC or funded wallet.
+ const payer = await Keypair.generate();
+ const account = {
+ address: payer.publicKey.toBase58(), publicKey: payer.publicKey.toBytes(),
+ chains: ["solana:devnet" as const], features: ["solana:signTransaction" as const],
+ };
+ assert.equal(validWalletAccount(account, "solana:devnet"), true);
+ assert.equal(validWalletAccount(account, "solana:mainnet"), false);
+ assert.equal(validWalletAccount({ ...account, publicKey: new Uint8Array(32) }, "solana:devnet"), false);
+
+ const transaction = new Transaction({
+ feePayer: payer.publicKey,
+ recentBlockhash: new PublicKey(new Uint8Array(32).fill(3)).toBase58(),
+ }).add(SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: new PublicKey(new Uint8Array(32).fill(4)), lamports: 1n }));
+ await transaction.sign(payer);
+ // Produce fixture wire bytes; production validation below checks every signature strictly.
+ const wire = await transaction.serialize({ verifySignatures: false });
+ const verifying = validateSignedTransaction(transaction.serializeMessage(), wire, payer.publicKey);
+ let current = true;
+ current = false; // A wallet event arrives while the asynchronous verifier is running.
+ const signed = await verifying;
+ const record: PendingSolanaAction = {
+ version: 1, programId: new PublicKey(new Uint8Array(32).fill(5)).toBase58(),
+ cluster: "devnet", wallet: account.address, signature: signed.signature,
+ lastValidBlockHeight: 100, label: "Buy tokens", pool: null,
+ };
+ const key = journalKey(record.programId, record.cluster, record.wallet);
+ let raw: string | null = null;
+ const storage = { getItem: () => raw, setItem: (_key: string, value: string) => { raw = value; } };
+ assert.throws(() => persistJournalForSession(storage, key, record, () => current), /Wallet changed/);
+ assert.equal(raw, null, "a session changed during crypto must never reach the durable send boundary");
+ current = true;
+ persistJournalForSession(storage, key, record, () => current);
+ assert.deepEqual(readJournal(raw, record.programId, record.cluster, record.wallet), record);
+ assert.equal(JSON.parse(raw!).lastValidBlockHeight, 100, "existing v1 numeric journal schema is unchanged");
+});
+
+test("the signing hook awaits verification then rechecks the session before persistence and send", () => {
+ const hook = readFileSync(new URL("./useSolanaAction.ts", import.meta.url), "utf8");
+ const verification = hook.indexOf("const signed = await validateSignedTransaction(");
+ const sessionGuard = hook.indexOf("if (!reviewLive())", verification);
+ const persistence = hook.indexOf("persistJournalForSession(localStorage, key, record, reviewLive)", sessionGuard);
+ const submission = hook.indexOf("await submitSignedTransaction(connection, signed)", persistence);
+ assert.ok(verification >= 0 && sessionGuard > verification && persistence > sessionGuard && submission > persistence);
+ assert.doesNotMatch(hook.slice(sessionGuard, submission).replace(/\/\/[^\n]*/g, ""), /\bawait\b/);
+});
diff --git a/app/src/lib/solana/config.test.ts b/app/src/lib/solana/config.test.ts
index 886044f5..d544ccb4 100644
--- a/app/src/lib/solana/config.test.ts
+++ b/app/src/lib/solana/config.test.ts
@@ -5,11 +5,25 @@ import {
formatUnits,
parseUnits,
publicKey,
+ requireLamports,
+ requireRent,
solanaConfig,
type Deployment,
} from "./config.ts";
const key = "So11111111111111111111111111111111111111112";
+test("v3 lamports and rent stay exact above the JavaScript number boundary", () => {
+ const large = BigInt(Number.MAX_SAFE_INTEGER) + 2n;
+ assert.equal(requireLamports(large), large);
+ assert.equal(requireRent(large), large);
+ assert.equal(formatUnits(requireRent(large), 9), "9007199.254740993");
+ assert.equal(requireLamports(0n), 0n);
+ assert.throws(() => requireRent(0n));
+ for (const invalid of [null, undefined, "5000", 5000, -1n, 1n << 64n, NaN, Infinity]) {
+ assert.throws(() => requireLamports(invalid));
+ assert.throws(() => requireRent(invalid));
+ }
+});
const blank: Deployment = {
schema: 1,
cluster: "mainnet-beta",
diff --git a/app/src/lib/solana/config.ts b/app/src/lib/solana/config.ts
index d9f8ef6e..cfe411e4 100644
--- a/app/src/lib/solana/config.ts
+++ b/app/src/lib/solana/config.ts
@@ -100,12 +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)
+/** v3 RPC amounts must remain exact integers through review and display. */
+export function requireLamports(value: unknown): bigint {
+ if (typeof value !== "bigint" || value < 0n || value > (1n << 64n) - 1n)
+ throw new Error("Could not read an exact SOL amount from Solana RPC. Try again.");
+ return value;
+}
+
+/** Zero rent is not a valid estimate; do not present a failed read as a free account. */
+export function requireRent(lamports: unknown): bigint {
+ if (typeof lamports !== "bigint" || lamports <= 0n || lamports > (1n << 64n) - 1n)
throw new Error("Could not read account rent from Solana RPC. Try again.");
return lamports;
}
diff --git a/app/src/lib/solana/history.test.ts b/app/src/lib/solana/history.test.ts
index 9d8dc0cd..c7e13747 100644
--- a/app/src/lib/solana/history.test.ts
+++ b/app/src/lib/solana/history.test.ts
@@ -49,8 +49,8 @@ function event(
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);
+ Buffer.from(pool.toBytes()).copy(bytes, 8);
+ Buffer.from(trader.toBytes()).copy(bytes, 40);
bytes.writeBigUInt64LE(sequence, 72);
bytes[80] = isBuy ? 1 : 0;
[
@@ -88,11 +88,11 @@ 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 } = {},
+ options: { failed?: boolean; keys?: PublicKey[]; slot?: bigint; blockTime?: bigint | null } = {},
): VersionedTransactionResponse {
const keys = options.keys ?? [trader, pool, program, other];
return {
- slot: options.slot ?? 50,
+ slot: options.slot ?? 50n,
blockTime: 600,
transaction: {
signatures: ["signature"],
@@ -109,7 +109,7 @@ function transaction(
},
meta: {
err: options.failed ? { InstructionError: [0, "InvalidArgument"] } : null,
- fee: 5000,
+ fee: 5000n,
preBalances: [],
postBalances: [],
logMessages: logs,
@@ -275,7 +275,7 @@ test("reject signature, account, slot and metadata mismatches; ignore swaps for
);
assert.equal(
parseSolanaTransaction(
- transaction(logs, { slot: 51 }),
+ transaction(logs, { slot: 51n }),
"signature",
program,
snapshot(),
@@ -290,7 +290,7 @@ test("reject signature, account, slot and metadata mismatches; ignore swaps for
true,
);
const foreign = Buffer.from(first.bytes);
- other.toBuffer().copy(foreign, 8);
+ Buffer.from(other.toBytes()).copy(foreign, 8);
assert.deepEqual(
parseSolanaSwapLogs(
[invoke(program), emit(foreign), success(program)],
@@ -301,6 +301,46 @@ test("reject signature, account, slot and metadata mismatches; ignore swaps for
);
});
+test("RPC bigint history stays JSON-safe and never rounds oversized slots or timestamps", async () => {
+ const logs = Object.freeze([invoke(program), emit(first.bytes), success(program)]);
+ const tx = transaction([...logs]);
+ const parsed = parseSolanaTransaction(tx, "signature", program, snapshot());
+ assert.equal(parsed.events[0].trade.slot, 50);
+ assert.equal(parsed.events[0].trade.blockTime, 600);
+ assert.doesNotThrow(() => JSON.stringify(parsed.events.map((item) => item.trade)));
+ for (const slot of [-1n, 9007199254740993n]) {
+ assert.equal(parseSolanaTransaction({ ...tx, slot }, "signature", program, snapshot()).rejected, true);
+ }
+ for (const blockTime of [-1n, 9007199254740993n]) {
+ const untimed = parseSolanaTransaction({ ...tx, blockTime }, "signature", program, snapshot());
+ assert.equal(untimed.rejected, false);
+ assert.equal(untimed.events[0].trade.blockTime, null);
+ assert.equal(recentHistoryFromEvents(untimed.events, 1n).candles.length, 0);
+ }
+ let reads = 0;
+ const fake = {
+ getSignaturesForAddress: async (_address: PublicKey, options: { minContextSlot: bigint }) => {
+ reads++;
+ assert.equal(options.minContextSlot, 50n);
+ return Object.freeze([
+ { signature: "too-large", slot: 9007199254740993n, err: null, confirmationStatus: "confirmed" },
+ { signature: "negative", slot: -1n, err: null, confirmationStatus: "confirmed" },
+ { signature: "signature", slot: 50n, err: null, confirmationStatus: "confirmed" },
+ ]);
+ },
+ getTransaction: async (signature: string) => {
+ assert.equal(signature, "signature");
+ return Object.freeze({ ...tx, meta: Object.freeze({ ...tx.meta!, logMessages: logs }) });
+ },
+ } as unknown as Connection;
+ const history = await fetchRecentSolanaHistory(fake, program, snapshot(), new Map());
+ assert.equal(history.tradeCount, 1);
+ assert.equal(history.snapshotSlot, 50);
+ assert.doesNotThrow(() => JSON.stringify(history));
+ await assert.rejects(fetchRecentSolanaHistory(fake, program, { ...snapshot(), slot: Number.MAX_SAFE_INTEGER + 1 }), /snapshot slot/);
+ assert.equal(reads, 1);
+});
+
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(
@@ -429,7 +469,7 @@ test("refreshes reuse confirmed transactions and refetch a signature the listing
success(other),
];
let calls = 0;
- let listedSlot = 50;
+ let listedSlot = 50n;
const fake = {
getSignaturesForAddress: async () => [
{
@@ -452,8 +492,8 @@ test("refreshes reuse confirmed transactions and refetch a signature the listing
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;
+ listedSlot = 49n;
await fetchRecentSolanaHistory(fake, program, snapshot(), transactions);
assert.equal(calls, 2);
- assert.equal(transactions.get("signature")?.slot, 49);
+ assert.equal(transactions.get("signature")?.slot, 49n);
});
diff --git a/app/src/lib/solana/history.ts b/app/src/lib/solana/history.ts
index c6a3c0c7..3b1d0c98 100644
--- a/app/src/lib/solana/history.ts
+++ b/app/src/lib/solana/history.ts
@@ -85,6 +85,16 @@ function checked(value: bigint, max = U128_MAX): bigint {
if (value < 0n || value > max) throw new Error("Invalid history arithmetic");
return value;
}
+/** RPC v3 uses bigint; our JSON/chart boundary accepts only exact nonnegative numbers. */
+function displayNumber(value: number | bigint | null | undefined): number | null {
+ if (typeof value === "bigint")
+ return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER)
+ ? Number(value)
+ : null;
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
+ ? value
+ : null;
+}
function decimal(value: bigint, decimals: number): string {
const text = checked(value)
.toString()
@@ -243,13 +253,13 @@ export function parseSolanaTransaction(
programId: PublicKey,
snapshot: PoolSnapshot,
): { rejected: boolean; events: RawTrade[] } {
+ const slot = displayNumber(tx.slot);
if (
!tx.meta ||
tx.meta.err ||
tx.transaction.signatures[0] !== signature ||
- !Number.isSafeInteger(tx.slot) ||
- tx.slot < 0 ||
- tx.slot > snapshot.slot
+ slot === null ||
+ slot > snapshot.slot
)
return { rejected: true, events: [] };
const keys = [
@@ -262,13 +272,10 @@ export function parseSolanaTransaction(
!keys.some((key) => key.equals(programId))
)
return { rejected: true, events: [] };
- const blockTime =
- Number.isSafeInteger(tx.blockTime) && (tx.blockTime ?? -1) >= 0
- ? tx.blockTime!
- : null;
+ const blockTime = displayNumber(tx.blockTime);
return parseSolanaSwapLogs(tx.meta.logMessages, programId, {
signature,
- slot: tx.slot,
+ slot,
blockTime,
pool: snapshot.address,
curve: snapshot.pool,
@@ -385,10 +392,12 @@ export async function fetchRecentSolanaHistory(
snapshot: PoolSnapshot,
transactions: TransactionCache = transactionCache,
): Promise {
+ if (displayNumber(snapshot.slot) === null)
+ throw new Error("Invalid history snapshot slot");
const signatures = (
await connection.getSignaturesForAddress(
snapshot.address,
- { limit: HISTORY_SIGNATURE_LIMIT, minContextSlot: snapshot.slot },
+ { limit: HISTORY_SIGNATURE_LIMIT, minContextSlot: BigInt(snapshot.slot) },
"confirmed",
)
).slice(0, HISTORY_SIGNATURE_LIMIT);
@@ -400,6 +409,7 @@ export async function fetchRecentSolanaHistory(
const candidates = unique.filter(
(signature) =>
!signature.err &&
+ displayNumber(signature.slot) !== null &&
signature.slot <= snapshot.slot &&
signature.confirmationStatus !== "processed",
);
diff --git a/app/src/lib/solana/rpc-policy.test.ts b/app/src/lib/solana/rpc-policy.test.ts
index ad40251d..c8910ac5 100644
--- a/app/src/lib/solana/rpc-policy.test.ts
+++ b/app/src/lib/solana/rpc-policy.test.ts
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ Connection,
Keypair,
PublicKey,
SystemProgram,
@@ -9,6 +10,8 @@ import {
} from "@solana/web3.js";
import { Buffer } from "buffer";
import { allowedSolanaRpc } from "./rpc-policy.ts";
+import { solanaRpcError } from "./rpc-errors.ts";
+import { submitSignedTransaction, validateSignedTransaction } from "../../../packages/solana-sdk/src/transactions.ts";
const program = new PublicKey("Fg6PaFpoGXkYsidMpWxTWqkZPknxNpEMTZfkhHQjBcqr");
const request = (method: string, params: unknown[] = []) => ({
@@ -17,140 +20,102 @@ const request = (method: string, params: unknown[] = []) => ({
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,
- );
+const allowed = async (value: unknown) =>
+ (await allowedSolanaRpc(value, program.toBase58())) !== null;
+
+test("RPC rejects broad relay methods and oversized account/history batches", async () => {
+ for (const value of [
+ request("requestAirdrop"),
+ request("getProgramAccounts", [SystemProgram.programId.toBase58()]),
+ request("getMultipleAccounts", [Array(17).fill(program.toBase58())]),
+ request("getSignaturesForAddress", [program.toBase58(), { limit: 101 }]),
+ request("getSignaturesForAddress", [program.toBase58(), { limit: 100 }]),
+ request("getTransaction", [program.toBase58()]),
+ request("getTokenAccountsByOwner", [program.toBase58()]),
+ request("getBalance", ["not-a-key"]),
+ request("getFeeForMessage", ["x".repeat(1645)]),
+ ]) assert.equal(await allowed(value), false);
+ const value = request("getLatestBlockhash", [{ commitment: "confirmed" }]);
+ assert.equal(await allowedSolanaRpc(value, program.toBase58()), value);
});
-test("RPC sends only fully signed program transactions; simulation never signs", () => {
- const signer = Keypair.generate(); // ephemeral, offline test only; never persisted
+
+test("RPC sends only fully signed program transactions; simulation never signs", async () => {
+ const signer = await Keypair.generate(); // Ephemeral offline fixture; never persisted or sent.
const tx = new Transaction({
feePayer: signer.publicKey,
recentBlockhash: program.toBase58(),
- }).add(
+ }).add(new TransactionInstruction({ programId: program, keys: [], data: Buffer.alloc(8) }));
+ const wire = async () => Buffer.from(await tx.serialize({
+ requireAllSignatures: false,
+ verifySignatures: false,
+ })).toString("base64");
+ assert.equal(await allowed(request("sendTransaction", [await wire(), { encoding: "base64" }])), false);
+ assert.equal(await allowed(request("simulateTransaction", [await wire(), { encoding: "base64", sigVerify: false }])), true);
+ await tx.sign(signer);
+ assert.equal(await allowed(request("sendTransaction", [await wire(), { encoding: "base64" }])), true);
+ assert.equal(await allowed(request("sendTransaction", [await wire(), { encoding: "base64", skipPreflight: true }])), false);
+
+ // v3 verifySignatures returns a Promise. A truthiness check would allow these corrupt bytes.
+ const invalid = Buffer.from(await wire(), "base64");
+ invalid[1] ^= 1;
+ assert.equal(await allowedSolanaRpc(request("sendTransaction", [invalid.toString("base64"), { encoding: "base64" }]), program.toBase58()), null);
+ const trailing = Buffer.concat([Buffer.from(await wire(), "base64"), Buffer.from([0])]);
+ assert.equal(await allowed(request("sendTransaction", [trailing.toString("base64"), { encoding: "base64" }])), false);
+ assert.equal(await allowed(request("simulateTransaction", [trailing.toString("base64"), { encoding: "base64" }])), false);
+
+ tx.instructions = [SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: program, lamports: 1n })];
+ await tx.sign(signer);
+ assert.equal(await allowed(request("sendTransaction", [await wire(), { encoding: "base64" }])), false);
+});
+
+test("RPC awaits verification of every required signature", async () => {
+ const payer = await Keypair.generate();
+ const missing = await Keypair.generate();
+ const tx = new Transaction({ feePayer: payer.publicKey, recentBlockhash: program.toBase58() }).add(
new TransactionInstruction({
programId: program,
- keys: [],
+ keys: [{ pubkey: missing.publicKey, isSigner: true, isWritable: false }],
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,
- );
+ await tx.partialSign(payer);
+ const bytes = await tx.serialize({ requireAllSignatures: false, verifySignatures: false });
+ assert.equal(await allowedSolanaRpc(request("sendTransaction", [Buffer.from(bytes).toString("base64"), { encoding: "base64" }]), program.toBase58()), null);
+});
+
+test("RPC rejects unsafe request numerics rather than relaying rounded slots", async () => {
+ for (const minContextSlot of [Number.MAX_SAFE_INTEGER + 1, -1, 0.5, Infinity, 9007199254740993n]) {
+ assert.equal(await allowed(request("getBalance", [program.toBase58(), { minContextSlot }])), false);
+ }
+ assert.equal(await allowed({ ...request("getSlot"), id: Number.MAX_SAFE_INTEGER + 1 }), false);
+ assert.equal(await allowed(request("getBalance", [program.toBase58(), { minContextSlot: Number.MAX_SAFE_INTEGER }])), true);
+});
+
+test("RPC allows the epoch read that pairs a slot with its block height", async () => {
+ assert.equal(await allowed(request("getEpochInfo", [{ commitment: "finalized" }])), true);
});
-test("RPC allows the epoch read that pairs a slot with its block height", () => {
- assert.equal(
- allowedSolanaRpc(
- request("getEpochInfo", [{ commitment: "finalized" }]),
- program.toBase58(),
- ),
- true,
+test("v3 send errors keep safe retry classification after proxy validation and sanitization", async () => {
+ const signer = await Keypair.generate();
+ const tx = new Transaction({ feePayer: signer.publicKey, recentBlockhash: program.toBase58() }).add(
+ new TransactionInstruction({ programId: program, keys: [], data: Buffer.alloc(8) }),
);
+ await tx.sign(signer);
+ const signed = await validateSignedTransaction(tx.serializeMessage(), await tx.serialize({ verifySignatures: false }), signer.publicKey);
+ let error: unknown = { code: -32002, message: "Private upstream wording", data: { err: "AlreadyProcessed", logs: [] } };
+ const connection = new Connection("https://offline.invalid", {
+ commitment: "confirmed",
+ fetch: async (_input, init) => {
+ const body = JSON.parse(String(init?.body));
+ const rpc = await allowedSolanaRpc(body, program.toBase58());
+ assert.notEqual(rpc, null);
+ assert.equal(rpc!.method, "sendTransaction");
+ return Response.json({ jsonrpc: "2.0", id: rpc!.id, error: solanaRpcError(error, rpc!.method) });
+ },
+ });
+ assert.equal((await submitSignedTransaction(connection, signed)).status, "unknown");
+ error = { code: -32002, message: "Private upstream wording", data: { err: { InstructionError: [0, { Custom: 6010 }] }, logs: [] } };
+ const rejected = await submitSignedTransaction(connection, signed);
+ assert.equal(rejected.status, "rejected");
+ assert.equal(rejected.error, "Minimum output not met");
});
diff --git a/app/src/lib/solana/rpc-policy.ts b/app/src/lib/solana/rpc-policy.ts
index 85d18ee3..593c4727 100644
--- a/app/src/lib/solana/rpc-policy.ts
+++ b/app/src/lib/solana/rpc-policy.ts
@@ -1,6 +1,7 @@
import { Transaction } from "@solana/web3.js";
import { Buffer } from "buffer";
import { publicKey } from "./config";
+import { verifyTransactionSignatures } from "../../../packages/solana-sdk/src/transactions";
const READS = new Set([
"getAccountInfo",
@@ -23,42 +24,58 @@ export type RpcRequest = {
params: unknown[];
};
+/** JSON numbers are already parsed here. Never relay a rounded slot, size or other RPC integer. */
+function exactRequestNumbers(values: readonly unknown[]): boolean {
+ const pending = [...values];
+ while (pending.length) {
+ const value = pending.pop();
+ if (typeof value === "number" && (!Number.isSafeInteger(value) || value < 0))
+ return false;
+ if (typeof value === "bigint") return false; // Not a JSON request value.
+ if (Array.isArray(value)) pending.push(...value);
+ else if (value && typeof value === "object") pending.push(...Object.values(value));
+ }
+ return true;
+}
+
/** The proxy is not a general RPC relay. No airdrops, subscriptions, or arbitrary broadcasts. */
-export function allowedSolanaRpc(
+export async function allowedSolanaRpc(
value: unknown,
programId: string,
-): value is RpcRequest {
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+): Promise {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const r = value as Partial;
if (
r.jsonrpc !== "2.0" ||
!(typeof r.id === "number" || typeof r.id === "string") ||
+ (typeof r.id === "number" && !Number.isSafeInteger(r.id)) ||
typeof r.method !== "string" ||
!Array.isArray(r.params) ||
- r.params.length > 4
+ r.params.length > 4 ||
+ !exactRequestNumbers(r.params)
)
- return false;
+ return null;
if (r.method === "getProgramAccounts" && r.params[0] !== programId)
- return false;
+ return null;
if (
["getAccountInfo", "getBalance"].includes(r.method) &&
!publicKey(r.params[0])
)
- return false;
+ return null;
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;
+ return null;
if (
r.method === "getMultipleAccounts" &&
(!Array.isArray(r.params[0]) ||
r.params[0].length > 16 ||
r.params[0].some((key) => !publicKey(key)))
)
- return false;
+ return null;
if (
r.method === "getSignatureStatuses" &&
(!Array.isArray(r.params[0]) ||
@@ -68,17 +85,17 @@ export function allowedSolanaRpc(
typeof s !== "string" || !/^[1-9A-HJ-NP-Za-km-z]{64,88}$/.test(s),
))
)
- return false;
+ return null;
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;
+ return null;
+ if (READS.has(r.method)) return r as RpcRequest;
if (r.method !== "sendTransaction" && r.method !== "simulateTransaction")
- return false;
+ return null;
const encoded = r.params[0];
const options = r.params[1] as
| { encoding?: string; skipPreflight?: boolean; sigVerify?: boolean }
@@ -89,14 +106,17 @@ export function allowedSolanaRpc(
!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) ||
options?.encoding !== "base64"
)
- return false;
+ return null;
try {
const bytes = Buffer.from(encoded, "base64");
if (bytes.length > 1232 || bytes.toString("base64") !== encoded)
- return false;
+ return null;
const tx = Transaction.from(bytes);
+ // Verify precisely the wire bytes forwarded upstream, not a normalized message that ignores trailing data.
+ const canonical = await tx.serialize({ requireAllSignatures: false, verifySignatures: false });
+ if (!Buffer.from(canonical).equals(bytes)) return null;
if (!tx.instructions.some((ix) => ix.programId.toBase58() === programId))
- return false;
+ return null;
const approved = new Set([
programId,
"11111111111111111111111111111111",
@@ -104,12 +124,13 @@ export function allowedSolanaRpc(
"ComputeBudget111111111111111111111111111111",
]);
if (tx.instructions.some((ix) => !approved.has(ix.programId.toBase58())))
- return false;
- return (
- r.method === "simulateTransaction" ||
- (options.skipPreflight !== true && tx.verifySignatures())
- );
+ return null;
+ if (r.method === "simulateTransaction") return r as RpcRequest;
+ // v3 verifies signatures asynchronously. Never treat the Promise itself as approval.
+ return options.skipPreflight !== true && (await verifyTransactionSignatures(tx))
+ ? (r as RpcRequest)
+ : null;
} catch {
- return false;
+ return null;
}
}
diff --git a/app/src/lib/solana/sdk-packaging.test.ts b/app/src/lib/solana/sdk-packaging.test.ts
new file mode 100644
index 00000000..de77294d
--- /dev/null
+++ b/app/src/lib/solana/sdk-packaging.test.ts
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { parseAmount } from "@openlaunch/solana-sdk";
+import { parseAmount as parseMathAmount } from "@openlaunch/solana-sdk/math";
+
+function appJson(path: string) {
+ return JSON.parse(readFileSync(new URL(`../../../${path}`, import.meta.url), "utf8"));
+}
+
+test("the app and its tests resolve both SDK entry points directly to source", () => {
+ for (const config of ["tsconfig.json", "tsconfig.test.json"]) {
+ const paths = appJson(config).compilerOptions.paths;
+ assert.deepEqual(paths["@openlaunch/solana-sdk"], ["./packages/solana-sdk/src/index.ts"]);
+ assert.deepEqual(paths["@openlaunch/solana-sdk/math"], ["./packages/solana-sdk/src/math.ts"]);
+ }
+ assert.equal(parseAmount("1.000001", 6), 1_000_001n);
+ assert.equal(parseMathAmount("1.000001", 6), 1_000_001n);
+});
+
+test("the app installs SDK dependencies from the registry, without a local package link", () => {
+ const app = appJson("package.json");
+ const sdk = appJson("packages/solana-sdk/package.json");
+ const lock = appJson("package-lock.json");
+ assert.equal(app.dependencies["@openlaunch/solana-sdk"], undefined);
+ assert.equal(lock.packages["node_modules/@openlaunch/solana-sdk"], undefined);
+ assert.equal(lock.packages["packages/solana-sdk"], undefined);
+ for (const key of Object.keys(lock.packages)) {
+ assert.doesNotMatch(key, /(?:^|\/)node_modules\/(?:bufferutil|utf-8-validate|rpc-websockets|jayson)$/,
+ "the migrated client must not reintroduce the legacy native WebSocket dependency chain");
+ }
+ assert.equal(app.dependencies["@solana/keys"], "8.5.0", "retain strict small-order signature protection");
+ for (const [name, version] of Object.entries(sdk.dependencies)) {
+ assert.equal(app.dependencies[name], version, `${name} must be installed by the app`);
+ }
+});
+
+test("the standard app test gate retains SDK and independent-model coverage", () => {
+ const { scripts } = appJson("package.json");
+ assert.match(scripts.test, /&& npm run test:solana && node scripts\/solana-checks\.mjs --ci$/);
+ assert.match(scripts["test:solana"], /tsc --noEmit -p packages\/solana-sdk\/tsconfig\.json/);
+ assert.match(scripts["test:solana"], /tsx --test "packages\/solana-sdk\/tests\/\*\.test\.ts"/);
+ assert.match(scripts["test:solana"], /node --experimental-strip-types --test "\.\.\/packages\/solana-model\/\*\.test\.mjs"/);
+});
diff --git a/app/src/lib/solana/useSolanaAction.ts b/app/src/lib/solana/useSolanaAction.ts
index aba33cb6..10c8e45c 100644
--- a/app/src/lib/solana/useSolanaAction.ts
+++ b/app/src/lib/solana/useSolanaAction.ts
@@ -23,7 +23,7 @@ import {
verifyImmutableProgram,
type PreparedTransaction,
} from "@openlaunch/solana-sdk";
-import type { SolanaConfig } from "./config";
+import { requireLamports, type SolanaConfig } from "./config";
import {
clearJournalIfCurrent,
journalKey,
@@ -44,7 +44,7 @@ export type ActionReview = {
prepared: PreparedTransaction;
payer: string;
pool: string | null;
- networkFee: number;
+ networkFee: bigint;
/** The program's own deadline for a trade (its expiry slot), checked with the blockhash before signing. */
expirySlot: bigint | null;
};
@@ -350,12 +350,9 @@ export function useSolanaAction(
tx.transaction.compileMessage(),
"confirmed",
);
- if (
- fee.value === null ||
- !Number.isSafeInteger(fee.value) ||
- fee.value < 0
- )
+ if (fee.value === null)
throw new Error("Could not estimate the transaction fee. Try again.");
+ const networkFee = requireLamports(fee.value);
if (!reviewLive())
throw new Error(
"The wallet or selected pool changed. Review the current selection again.",
@@ -375,7 +372,7 @@ export function useSolanaAction(
prepared: tx,
payer: address,
pool,
- networkFee: fee.value,
+ networkFee,
expirySlot,
},
});
@@ -446,17 +443,21 @@ export function useSolanaAction(
"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(
+ const signed = await validateSignedTransaction(
review.prepared.messageBytes,
signedBytes,
new PublicKey(address),
);
+ // v3 verifies signatures asynchronously. Recheck the session after it
+ // finishes; no await may separate this guard, journal write and send.
+ if (!reviewLive())
+ throw new Error(
+ "The wallet or selected pool changed during signature verification. Nothing was broadcast.",
+ );
const record: PendingSolanaAction = {
version: 1,
programId: config.programId,
diff --git a/app/tsconfig.json b/app/tsconfig.json
index d233049b..054426b0 100644
--- a/app/tsconfig.json
+++ b/app/tsconfig.json
@@ -15,7 +15,11 @@
"incremental": true,
"allowImportingTsExtensions": true,
"plugins": [{ "name": "next" }],
- "paths": { "@/*": ["./src/*"] }
+ "paths": {
+ "@/*": ["./src/*"],
+ "@openlaunch/solana-sdk": ["./packages/solana-sdk/src/index.ts"],
+ "@openlaunch/solana-sdk/math": ["./packages/solana-sdk/src/math.ts"]
+ }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules", "**/*.test.ts", "scripts", "skills"]
diff --git a/app/tsconfig.test.json b/app/tsconfig.test.json
index 7b8b18ac..8acfa06c 100644
--- a/app/tsconfig.test.json
+++ b/app/tsconfig.test.json
@@ -3,6 +3,8 @@
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
+ "@openlaunch/solana-sdk": ["./packages/solana-sdk/src/index.ts"],
+ "@openlaunch/solana-sdk/math": ["./packages/solana-sdk/src/math.ts"],
"@/lib/db": ["./src/lib/db-mock.ts"],
"@/lib/chain": ["./src/lib/chain-mock.ts"]
}
diff --git a/docs/SOLANA_IMMUTABLE_POOL_PLAN.md b/docs/SOLANA_IMMUTABLE_POOL_PLAN.md
index c609bc7c..3948e2ef 100644
--- a/docs/SOLANA_IMMUTABLE_POOL_PLAN.md
+++ b/docs/SOLANA_IMMUTABLE_POOL_PLAN.md
@@ -244,4 +244,4 @@ The initial planning sanity check has been replaced by persisted regression suit
- 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.
+The review candidate now includes the original pool program, SDK, independent model, isolated app workspace, read-only release verifier and CI checks. SDK/model checks and the pinned Rust host, SBFv3 build and offline runtime checks run through the existing app CI's `npm test`; no workflow or guard change is required. 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_RELEASE.md b/docs/SOLANA_RELEASE.md
index bc8b50fa..def305e9 100644
--- a/docs/SOLANA_RELEASE.md
+++ b/docs/SOLANA_RELEASE.md
@@ -9,14 +9,22 @@ Status: **review candidate, not a mainnet release**. The workspace stays separat
- 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.
+- Feature-gated RPC and history endpoints; committed Rust/app dependency lockfiles, build-only checks through the existing app CI job 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.
+See [BUILD.md](../solana/BUILD.md) for exact toolchain versions, fixture hash, commands, compute observations and limitations. The 10 October fixture passed 21 Rust host tests and 9 compiled-SBPFv3 LiteSVM tests; those results are historical evidence, not a fresh run of a later commit. `npm test` includes SDK typechecking, SDK tests and the independent economic model through `test:solana`. In CI it also runs all pinned Rust host, SBFv3 build, offline runtime and Clippy checks through `scripts/solana-checks.mjs`. App lint, TypeScript and production build remain separate checks. CI must 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.
+### 14 October client/guard follow-up
+
+The client now uses web3.js 3.0.2 and the strict `@solana/keys` 8.5.0 verifier. The local `file:` SDK dependency and legacy native WebSocket install-script dependencies are removed. SDK source aliases preserve the app-only build context. Wallet-message/ABI bytes and the v1 recovery-journal schema remain unchanged; asynchronous verification rechecks the live wallet/session before persistence and submission. Read [the SDK migration notes](../app/packages/solana-sdk/README.md) before changing these boundaries.
+
+The pinned Windows checks were rerun: 21 host tests, 9 actual-SBF offline runtime tests, formatting and Clippy passed. The incremental SBFv3 build retained the documented 373,464-byte fixture hash. This does not substitute for a clean independent build or real-wallet/network rehearsal.
+
+**Guard remains blocked by a separate existing dependency-policy mismatch.** A clean npm 10 lock regeneration exposes six `inBundle` entries inside `@tailwindcss/oxide-wasm32-wasi@4.3.3`. The optional development package's version, registry tarball and integrity are identical to main at `c14aae70acd723fe16e0562d721b284102e1f088`; main already lists its six `bundleDependencies` but lacks their child metadata. The guard refuses bundled children without their own registry hash. They are not new Solana runtime dependencies, but their actual provenance must not be hidden by deleting entries or inventing hashes. Maintainers must resolve the dependency/policy conflict separately; exact-head review alone cannot waive a hard block. The guard, deployed-source lock and CI workflow remain unchanged relative to main.
+
## Reproduce without deploying
From `app/`:
@@ -26,13 +34,12 @@ 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.
+Outside CI, `npm test` explicitly skips Rust/SBF tooling so an ordinary app test does not install a compiler. Run `npm run test:solana-runtime` explicitly from `app/` on Linux/macOS, or use the Windows commands in [BUILD.md](../solana/BUILD.md). `npm run test:solana` runs just SDK typechecking, SDK tests and model tests. GitHub Actions always runs the Rust/SBF checks as part of its existing `npm test` step; no check is deferred and the reviewed workflow/guard remain unchanged. Missing tools or failed checks fail the CI command rather than silently skip it.
+
+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. Consolidating checks does not waive dependency or security review.
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.
diff --git a/solana/BUILD.md b/solana/BUILD.md
index 03824209..8ee7b2ee 100644
--- a/solana/BUILD.md
+++ b/solana/BUILD.md
@@ -2,6 +2,32 @@
No deployment is performed by these commands. The source contains an undeployed compilation fixture ID. Never expose this fixture as a real Solana launch option. Kevin must bind the reviewed real program ID into source, app configuration and the release manifest before the final build and audit comparison.
+## CI and local entry points
+
+The existing app CI runs `npm test`. That command includes SDK typechecking, SDK tests and the independent economic model, then calls `app/scripts/solana-checks.mjs --ci`. When `CI=true` or `GITHUB_ACTIONS=true`, the runner executes every required Rust/SBF check below, in order, with a shared 35-minute budget. Missing source/tooling, failed commands, stack diagnostics or missing/empty bytecode fail the check; none is downgraded to a warning. The reviewed CI workflow and security guard are unchanged. Consolidation retains all checks under the app job rather than a separate Solana job, so reviewers must inspect that job's Solana log sections as well as its app results.
+
+Outside CI, ordinary `npm test` explicitly reports that Rust/SBF checks were skipped. To run the full build/runtime checks locally from `app/`:
+
+```sh
+npm run test:solana-runtime
+```
+
+The combined runner requires Linux/macOS with `rustup`, `cargo` and Bash on `PATH`. On Windows use WSL, or the explicit Windows build and runtime commands below; the runner fails clearly instead of guessing a Git Bash location. It resolves the repository's `solana/` directory from its own file and requires the full checkout. The app-only Docker build runs `npm ci` and `npm run build`, not tests, so this adds no Rust toolchain to the production image.
+
+The runner pins the host toolchain and executes the following commands from `solana/`. The SBF script separately pins platform-tools v1.56 and SBPFv3, rejects compiler diagnostics and requires a nonempty ELF before runtime tests:
+
+```sh
+rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy
+cargo +1.97.1 fmt --all --check
+cargo +1.97.1 test -p launch-pool --locked
+cargo +1.97.1 install cargo-build-sbf --version 4.2.0 --locked --root .tools
+bash scripts/check-sbf.sh
+cargo +1.97.1 test --workspace --features svm-tests --locked
+cargo +1.97.1 clippy --workspace --all-targets --features svm-tests --locked -- -D warnings
+```
+
+Retain the exact source commit, toolchain provenance, complete compiler/test logs and ELF hash for release review. These are build/test commands only: no RPC credentials, wallet keys, funded signatures, deployment or authority mutation. A green consolidated job does not waive dependency review, independent protocol/economic review or the remaining mainnet gates.
+
## Host checks
From `solana/`:
From ff67fa3b36a91616657925c8c1158fa783e238a5 Mon Sep 17 00:00:00 2001
From: Vasanth T
Date: Wed, 14 Oct 2026 16:35:42 +0530
Subject: [PATCH 11/11] fix(deps): drop the lockfile's unhashed bundled entries
that pr-guard blocks
The regenerated lockfile listed the six packages that
@tailwindcss/oxide-wasm32-wasi bundles (@emnapi/core, @emnapi/runtime,
@emnapi/wasi-threads, @napi-rs/wasm-runtime, @tybys/wasm-util, tslib) as
nested inBundle entries with no integrity hash of their own. main's guard
refuses any inBundle entry, which failed pr-guard and the guard's own test
that the repository's lockfile passes it. main's lockfile has no such
entries; the bundled code ships inside the parent's hashed tarball, and
npm 10's npm ci accepts the lockfile without them.
---
app/package-lock.json | 66 -------------------------------------------
1 file changed, 66 deletions(-)
diff --git a/app/package-lock.json b/app/package-lock.json
index 355a18e7..1e0b3efe 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -3785,72 +3785,6 @@
"node": ">=14.0.0"
}
},
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
- "version": "1.11.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.2",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
- "version": "1.2.2",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.4",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.1"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
- "version": "0.10.2",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
- "version": "2.8.1",
- "dev": true,
- "inBundle": true,
- "license": "0BSD",
- "optional": true
- },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",