feat(transmute): sell-route detection (Jupiter) + design Rev 3
Re-prioritizes the core loop (sell→feed→close; burn for unsellable only) per
user direction. READ-ONLY this increment — quotes + risk flags only, no swap
build/sign, no funds moved.
- docs: Rev 3 — §5 scope, §6 TRANSMUTABLE active, new §6.1 (Jupiter Ultra
routing incl. pump.fun pre/post-graduation + Token-2022; 3rd-party-swap trust
model = simulate + lamports-delta ≥ min-out + sole-signer + no
SetAuthority/Approve/bad-CloseAccount; Shield; price-impact/slippage/dust
guards; Essence model 1 = opt-in off-chain tally, no custody).
- @pyre/core: SellInfo type + TokenAccountDto.sell.
- @pyre/api: keyless Jupiter client (lite-api: /swap/v1/quote + /ultra/v1/shield);
bounded /api/scan enrichment — upgrades INCINERATE_ONLY→TRANSMUTABLE when a
worthwhile route exists; dust gate (proceeds ≤ fee+rent → keep burn); price
impact >10% blocks; graceful degrade if Jupiter down.
- @pyre/web: shows "Sellable for ~X SOL", price impact, Shield chips; disabled
"Sell & feed the PYRE (soon)" CTA (execution is the next, audited step).
Tracker: Phase 6 "swap candidate detection" + "route quote preview" done.
typecheck 8/8, core 85, solana 19, web build green.
LIVE FINDING: both pump.fun tokens ARE routable via Jupiter (so no pump.fun
engine needed) but quote ~0.0000097 SOL each — far below their ~0.002 SOL rent,
so the dust gate correctly keeps them INCINERATE_ONLY ("not worth selling").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,15 +31,47 @@ import type {
|
|||||||
ScanSummary,
|
ScanSummary,
|
||||||
TokenAccountDto,
|
TokenAccountDto,
|
||||||
ParsedTokenAccount,
|
ParsedTokenAccount,
|
||||||
|
SellInfo,
|
||||||
} from "@pyre/core";
|
} from "@pyre/core";
|
||||||
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
|
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
|
||||||
import type {
|
import type {
|
||||||
BuildCloseEmptyResponse,
|
BuildCloseEmptyResponse,
|
||||||
ReceiptResponse,
|
ReceiptResponse,
|
||||||
} from "@pyre/core";
|
} from "@pyre/core";
|
||||||
|
import { getSellQuote, getShield } from "./jupiter.js";
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sell-quote enrichment guards (READ-ONLY: quotes + risk flags only).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Max INCINERATE_ONLY accounts probed for a sell route per scan. */
|
||||||
|
const SELL_ENRICH_CAP = 12;
|
||||||
|
|
||||||
|
/** Hard overall budget for the whole enrichment pass; keeps scan latency bounded. */
|
||||||
|
const SELL_ENRICH_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
/** Per-Jupiter-call network timeout. */
|
||||||
|
const SELL_QUOTE_TIMEOUT_MS = 3500;
|
||||||
|
|
||||||
|
/** Flat Solana base tx fee in lamports (5000) added to reclaimable rent in the dust gate. */
|
||||||
|
const TX_FEE_LAMPORTS = 5000n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price-impact cap, in basis points, sourced from config when present
|
||||||
|
* (`MAX_PRICE_IMPACT_BPS`) and otherwise defaulting to 300 bps (3.0%). Used as
|
||||||
|
* the WARN threshold; routability is only BLOCKED above the hard 10% ceiling.
|
||||||
|
*/
|
||||||
|
const MAX_PRICE_IMPACT_BPS =
|
||||||
|
(config as { maxPriceImpactBps?: number }).maxPriceImpactBps ?? 300;
|
||||||
|
|
||||||
|
/** Warn cap as a percent (e.g. 300 bps -> 3.0%). */
|
||||||
|
const PRICE_IMPACT_WARN_PCT = MAX_PRICE_IMPACT_BPS / 100;
|
||||||
|
|
||||||
|
/** Hard block ceiling: above this percent a route is never offered. */
|
||||||
|
const PRICE_IMPACT_BLOCK_PCT = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Well-known SPL Token + Token-2022 program ids (base58). Declared locally so
|
* Well-known SPL Token + Token-2022 program ids (base58). Declared locally so
|
||||||
* `@pyre/api` needs no new dependency on `@solana/spl-token`; only used to
|
* `@pyre/api` needs no new dependency on `@solana/spl-token`; only used to
|
||||||
@@ -53,6 +85,131 @@ const TOKEN_PROGRAM_IDS = new Set<string>([
|
|||||||
/** SPL Token `CloseAccount` instruction discriminator (first data byte). */
|
/** SPL Token `CloseAccount` instruction discriminator (first data byte). */
|
||||||
const CLOSE_ACCOUNT_IX = 9;
|
const CLOSE_ACCOUNT_IX = 9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich a bounded set of INCINERATE_ONLY DTOs with Jupiter sell info, mutating
|
||||||
|
* each DTO's `sell` field (and, when a sale is worthwhile, upgrading its
|
||||||
|
* classification to TRANSMUTABLE and adjusting `summary` counts).
|
||||||
|
*
|
||||||
|
* READ-ONLY: this only fetches quotes + Shield risk flags; it never builds or
|
||||||
|
* signs a swap. The pass is best-effort and bounded by an overall timeout — on
|
||||||
|
* timeout or any Jupiter failure, candidates are left INCINERATE_ONLY with at
|
||||||
|
* most `sell.note = "quote unavailable"`.
|
||||||
|
*
|
||||||
|
* Guard thresholds:
|
||||||
|
* - price impact > 10% (PRICE_IMPACT_BLOCK_PCT) -> not routable.
|
||||||
|
* - dust gate: net SOL out <= 5000 (tx fee) + the ATA's reclaimable rent
|
||||||
|
* -> not routable ("not worth selling"); the account stays INCINERATE_ONLY.
|
||||||
|
* - otherwise routable -> upgraded to TRANSMUTABLE.
|
||||||
|
*/
|
||||||
|
async function enrichWithSellQuotes(
|
||||||
|
probe: TokenAccountDto[],
|
||||||
|
summary: ScanSummary,
|
||||||
|
): Promise<void> {
|
||||||
|
// Overall budget: race the whole pass against a hard timeout so a slow
|
||||||
|
// Jupiter cannot inflate scan latency.
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const budget = new Promise<"timeout">((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve("timeout"), SELL_ENRICH_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
const work = (async (): Promise<void> => {
|
||||||
|
// Fetch quotes for every candidate in parallel, plus one batched Shield
|
||||||
|
// call — all concurrently so the pass is gated by the slowest single call.
|
||||||
|
const [quotes, shield] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
probe.map((d) =>
|
||||||
|
getSellQuote(d.mint, d.rawBalance, {
|
||||||
|
timeoutMs: SELL_QUOTE_TIMEOUT_MS,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
getShield(
|
||||||
|
probe.map((d) => d.mint),
|
||||||
|
{ timeoutMs: SELL_QUOTE_TIMEOUT_MS },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (let i = 0; i < probe.length; i++) {
|
||||||
|
const dto = probe[i];
|
||||||
|
const quote = quotes[i];
|
||||||
|
if (dto === undefined) continue;
|
||||||
|
|
||||||
|
const riskFlags = shield.get(dto.mint);
|
||||||
|
|
||||||
|
if (quote === null || quote === undefined) {
|
||||||
|
// No route found (or quote failed) — stays INCINERATE_ONLY.
|
||||||
|
dto.sell = withFlags({ routable: false, note: "no route" }, riskFlags);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { outLamports, priceImpactPct } = quote;
|
||||||
|
|
||||||
|
// Hard block: price impact above the 10% ceiling is never routable.
|
||||||
|
if (priceImpactPct > PRICE_IMPACT_BLOCK_PCT) {
|
||||||
|
dto.sell = withFlags(
|
||||||
|
{ routable: false, priceImpactPct, note: "price impact too high" },
|
||||||
|
riskFlags,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dust gate: a sale isn't worth it if proceeds <= tx fee + the rent you'd
|
||||||
|
// reclaim anyway by simply closing the (post-burn) ATA. BigInt-safe compare.
|
||||||
|
let outBig: bigint;
|
||||||
|
let rentBig: bigint;
|
||||||
|
try {
|
||||||
|
outBig = BigInt(outLamports);
|
||||||
|
rentBig = BigInt(dto.estimatedRentLamports);
|
||||||
|
} catch {
|
||||||
|
// Unparseable amount — treat as no usable quote, stay INCINERATE_ONLY.
|
||||||
|
dto.sell = withFlags({ routable: false, note: "no route" }, riskFlags);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (outBig <= TX_FEE_LAMPORTS + rentBig) {
|
||||||
|
dto.sell = withFlags(
|
||||||
|
{
|
||||||
|
routable: false,
|
||||||
|
estimatedSolLamports: outLamports,
|
||||||
|
priceImpactPct,
|
||||||
|
note: "not worth selling (rent ≥ sale value)",
|
||||||
|
},
|
||||||
|
riskFlags,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worthwhile route — mark routable and upgrade classification.
|
||||||
|
dto.sell = withFlags(
|
||||||
|
{ routable: true, estimatedSolLamports: outLamports, priceImpactPct },
|
||||||
|
riskFlags,
|
||||||
|
);
|
||||||
|
dto.classification = TokenClassification.TRANSMUTABLE;
|
||||||
|
if (summary.incinerateOnly > 0) summary.incinerateOnly -= 1;
|
||||||
|
summary.transmutable += 1;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outcome = await Promise.race([work, budget]);
|
||||||
|
if (outcome === "timeout") {
|
||||||
|
// Bounded out: annotate any still-unprobed candidate as unavailable.
|
||||||
|
for (const dto of probe) {
|
||||||
|
if (dto.sell === undefined) {
|
||||||
|
dto.sell = { routable: false, note: "quote unavailable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach Shield risk flags to a SellInfo when any were found. */
|
||||||
|
function withFlags(sell: SellInfo, flags: string[] | undefined): SellInfo {
|
||||||
|
if (flags !== undefined && flags.length > 0) sell.riskFlags = flags;
|
||||||
|
return sell;
|
||||||
|
}
|
||||||
|
|
||||||
// External RPC provider only — never run a validator/RPC node on the MVP VPS.
|
// External RPC provider only — never run a validator/RPC node on the MVP VPS.
|
||||||
const connection = new Connection(config.solanaRpcUrl, "confirmed");
|
const connection = new Connection(config.solanaRpcUrl, "confirmed");
|
||||||
|
|
||||||
@@ -187,6 +344,22 @@ app.post<{ Body: ScanBody }>(
|
|||||||
|
|
||||||
summary.estimatedRentLamports = rentSum.toString();
|
summary.estimatedRentLamports = rentSum.toString();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Sell-quote enrichment (READ-ONLY): probe whether INCINERATE_ONLY accounts
|
||||||
|
// could instead be sold to SOL. Quotes + risk flags only — no swap is built
|
||||||
|
// or signed here. Best-effort: any failure leaves the scan unchanged.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const candidates = dtos.filter(
|
||||||
|
(d) => d.classification === TokenClassification.INCINERATE_ONLY,
|
||||||
|
);
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
const probe = candidates.slice(0, SELL_ENRICH_CAP);
|
||||||
|
await enrichWithSellQuotes(probe, summary).catch((err) => {
|
||||||
|
// Never let enrichment failure break a scan — degrade silently.
|
||||||
|
request.log.warn({ err, wallet: walletPk.toBase58() }, "sell enrichment failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response: ScanResponse = {
|
const response: ScanResponse = {
|
||||||
scanId: randomUUID(),
|
scanId: randomUUID(),
|
||||||
wallet: walletPk.toBase58(),
|
wallet: walletPk.toBase58(),
|
||||||
|
|||||||
187
apps/api/src/jupiter.ts
Normal file
187
apps/api/src/jupiter.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// PYRE backend — keyless Jupiter client (Phase: READ-ONLY).
|
||||||
|
//
|
||||||
|
// Probes whether an SPL token can be sold to SOL, for DISPLAY ONLY. This module
|
||||||
|
// builds NO swap transactions, requests NO signatures, and moves NO funds. It
|
||||||
|
// only fetches quotes (`/swap/v1/quote`) and risk warnings (`/ultra/v1/shield`)
|
||||||
|
// from Jupiter's keyless lite endpoints.
|
||||||
|
//
|
||||||
|
// Hard rule: every exported call is wrapped so it NEVER throws. On any HTTP
|
||||||
|
// error, missing route, parse failure, or timeout it resolves to `null` / an
|
||||||
|
// empty map. Callers can therefore treat Jupiter as strictly best-effort and
|
||||||
|
// degrade the scan gracefully when it is unreachable.
|
||||||
|
|
||||||
|
/** Keyless Jupiter base. No API key is sent; these are the public lite hosts. */
|
||||||
|
const JUPITER_BASE = "https://lite-api.jup.ag";
|
||||||
|
|
||||||
|
/** Wrapped SOL mint — the output mint for every sell quote. */
|
||||||
|
const WSOL_MINT = "So11111111111111111111111111111111111111112";
|
||||||
|
|
||||||
|
/** Default slippage tolerance for quotes, in basis points (1% = 100 bps). */
|
||||||
|
const DEFAULT_SLIPPAGE_BPS = 100;
|
||||||
|
|
||||||
|
/** Default per-call network timeout, in milliseconds. */
|
||||||
|
const DEFAULT_TIMEOUT_MS = 3500;
|
||||||
|
|
||||||
|
/** Max mints per Shield request (endpoint accepts a bounded comma list). */
|
||||||
|
const SHIELD_CHUNK = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a SELL quote (inputMint -> wrapped SOL) from Jupiter.
|
||||||
|
*
|
||||||
|
* Returns `{ outLamports, priceImpactPct }` on a 200 with a numeric
|
||||||
|
* `outAmount`; returns `null` on non-200, no route, parse error, or timeout.
|
||||||
|
* NEVER throws.
|
||||||
|
*/
|
||||||
|
export async function getSellQuote(
|
||||||
|
inputMint: string,
|
||||||
|
rawAmount: string,
|
||||||
|
opts?: { slippageBps?: number; timeoutMs?: number },
|
||||||
|
): Promise<{ outLamports: string; priceImpactPct: number } | null> {
|
||||||
|
const slippageBps = opts?.slippageBps ?? DEFAULT_SLIPPAGE_BPS;
|
||||||
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`${JUPITER_BASE}/swap/v1/quote` +
|
||||||
|
`?inputMint=${encodeURIComponent(inputMint)}` +
|
||||||
|
`&outputMint=${WSOL_MINT}` +
|
||||||
|
`&amount=${encodeURIComponent(rawAmount)}` +
|
||||||
|
`&slippageBps=${slippageBps}` +
|
||||||
|
`&restrictIntermediateTokens=true`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(url, timeoutMs);
|
||||||
|
if (res === null || !res.ok) return null;
|
||||||
|
|
||||||
|
const body: unknown = await res.json();
|
||||||
|
if (body === null || typeof body !== "object") return null;
|
||||||
|
|
||||||
|
const record = body as Record<string, unknown>;
|
||||||
|
const outAmount = record["outAmount"];
|
||||||
|
const outNum = Number(outAmount);
|
||||||
|
// No route / missing or non-numeric outAmount -> treat as "no route".
|
||||||
|
if (
|
||||||
|
outAmount === undefined ||
|
||||||
|
outAmount === null ||
|
||||||
|
!Number.isFinite(outNum) ||
|
||||||
|
outNum <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impactRaw = record["priceImpactPct"];
|
||||||
|
const impactNum = Number(impactRaw ?? 0);
|
||||||
|
const priceImpactPct = Number.isFinite(impactNum) ? impactNum : 0;
|
||||||
|
|
||||||
|
return { outLamports: String(outAmount), priceImpactPct };
|
||||||
|
} catch {
|
||||||
|
// Any unexpected error (DNS, abort, JSON throw) -> no quote.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Jupiter Shield warnings for a set of mints.
|
||||||
|
*
|
||||||
|
* Resolves to a Map of mint -> array of warning type strings. Mints with no
|
||||||
|
* warnings (or that the call could not cover) are simply absent / map to `[]`.
|
||||||
|
* Reads the response defensively; ANY error yields an empty map. NEVER throws.
|
||||||
|
*
|
||||||
|
* Requests are chunked to <=30 mints per call.
|
||||||
|
*/
|
||||||
|
export async function getShield(
|
||||||
|
mints: string[],
|
||||||
|
opts?: { timeoutMs?: number },
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
// De-duplicate and drop blanks before chunking.
|
||||||
|
const unique = Array.from(
|
||||||
|
new Set(mints.filter((m) => typeof m === "string" && m.length > 0)),
|
||||||
|
);
|
||||||
|
if (unique.length === 0) return result;
|
||||||
|
|
||||||
|
for (let i = 0; i < unique.length; i += SHIELD_CHUNK) {
|
||||||
|
const chunk = unique.slice(i, i + SHIELD_CHUNK);
|
||||||
|
const url =
|
||||||
|
`${JUPITER_BASE}/ultra/v1/shield` +
|
||||||
|
`?mints=${encodeURIComponent(chunk.join(","))}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(url, timeoutMs);
|
||||||
|
if (res === null || !res.ok) continue;
|
||||||
|
|
||||||
|
const body: unknown = await res.json();
|
||||||
|
mergeShieldBody(body, result);
|
||||||
|
} catch {
|
||||||
|
// Best-effort per chunk; a failed chunk just contributes no flags.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensively extract per-mint warning type strings from a Shield response and
|
||||||
|
* merge them into `out`. The Shield payload shape can vary; we handle the
|
||||||
|
* documented `{ warnings: { <mint>: [{ type, ... }] } }` form plus a couple of
|
||||||
|
* tolerant fallbacks, and silently ignore anything we don't recognise.
|
||||||
|
*/
|
||||||
|
function mergeShieldBody(body: unknown, out: Map<string, string[]>): void {
|
||||||
|
if (body === null || typeof body !== "object") return;
|
||||||
|
|
||||||
|
// Shield returns warnings keyed by mint, typically under `warnings`.
|
||||||
|
const container = (body as Record<string, unknown>)["warnings"] ?? body;
|
||||||
|
if (container === null || typeof container !== "object") return;
|
||||||
|
|
||||||
|
for (const [mint, value] of Object.entries(
|
||||||
|
container as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
const flags = extractWarningTypes(value);
|
||||||
|
if (flags.length === 0) continue;
|
||||||
|
const existing = out.get(mint);
|
||||||
|
if (existing) existing.push(...flags);
|
||||||
|
else out.set(mint, flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull `type`/`name`/string warning identifiers out of a per-mint value. */
|
||||||
|
function extractWarningTypes(value: unknown): string[] {
|
||||||
|
const flags: string[] = [];
|
||||||
|
const items = Array.isArray(value) ? value : [value];
|
||||||
|
for (const item of items) {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
if (item.length > 0) flags.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item !== null && typeof item === "object") {
|
||||||
|
const rec = item as Record<string, unknown>;
|
||||||
|
const t = rec["type"] ?? rec["name"] ?? rec["warning"];
|
||||||
|
if (typeof t === "string" && t.length > 0) flags.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fetch` with an AbortController timeout. Resolves to the Response, or `null`
|
||||||
|
* on abort / network error. NEVER throws.
|
||||||
|
*/
|
||||||
|
async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -449,6 +449,79 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sell / transmute info on a scan row (display only) */
|
||||||
|
.sell-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem 0.65rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.sell-info__sol {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ember-bright);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.sell-info__impact {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.sell-info__impact--ok {
|
||||||
|
color: #7be3a3;
|
||||||
|
}
|
||||||
|
.sell-info__impact--caution {
|
||||||
|
color: #ffce6b;
|
||||||
|
}
|
||||||
|
.sell-info__impact--warn {
|
||||||
|
color: #ff7a6b;
|
||||||
|
}
|
||||||
|
.sell-info__flags {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.sell-chip {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
padding: 0.12rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffce6b;
|
||||||
|
background: rgba(255, 206, 107, 0.1);
|
||||||
|
border: 1px solid rgba(255, 206, 107, 0.32);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sell-info__note {
|
||||||
|
color: var(--color-smoke);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transmutable group explainer + coming-soon CTA */
|
||||||
|
.transmute {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px dashed rgba(255, 138, 61, 0.35);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: rgba(255, 87, 34, 0.04);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.transmute__explainer {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-smoke);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.transmute__cta {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.transmute__cta:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-note {
|
.preview-note {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
|
|||||||
@@ -59,6 +59,63 @@ function lamportsToSol(lamports: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Humanize Jupiter Shield risk flags (e.g. "HAS_FREEZE_AUTHORITY" -> "freeze authority").
|
||||||
|
function humanizeRiskFlag(flag: string): string {
|
||||||
|
return flag
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^has_/, "")
|
||||||
|
.replace(/_authority$/, " authority")
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price-impact tone: ok < 3%, caution 3–10%, warn ≥ 10%.
|
||||||
|
function impactTone(pct: number): "ok" | "caution" | "warn" {
|
||||||
|
if (pct < 3) return "ok";
|
||||||
|
if (pct < 10) return "caution";
|
||||||
|
return "warn";
|
||||||
|
}
|
||||||
|
|
||||||
|
function SellInfoBlock({ sell }: { sell: NonNullable<TokenAccountDto["sell"]> }) {
|
||||||
|
if (sell.routable) {
|
||||||
|
const sol =
|
||||||
|
sell.estimatedSolLamports != null
|
||||||
|
? lamportsToSol(sell.estimatedSolLamports)
|
||||||
|
: null;
|
||||||
|
const tone = sell.priceImpactPct != null ? impactTone(sell.priceImpactPct) : null;
|
||||||
|
return (
|
||||||
|
<div className="sell-info">
|
||||||
|
{sol != null && (
|
||||||
|
<span className="sell-info__sol">Sellable for ~{sol.toFixed(5)} SOL</span>
|
||||||
|
)}
|
||||||
|
{sell.priceImpactPct != null && tone && (
|
||||||
|
<span className={`sell-info__impact sell-info__impact--${tone}`}>
|
||||||
|
price impact {sell.priceImpactPct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{sell.riskFlags && sell.riskFlags.length > 0 && (
|
||||||
|
<span className="sell-info__flags">
|
||||||
|
{sell.riskFlags.map((f) => (
|
||||||
|
<span key={f} className="sell-chip" title={f}>
|
||||||
|
{humanizeRiskFlag(f)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Not routable / dust / impact too high — show the note muted.
|
||||||
|
if (sell.note) {
|
||||||
|
return (
|
||||||
|
<div className="sell-info">
|
||||||
|
<span className="sell-info__note">{sell.note}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function AccountRow({ account }: { account: TokenAccountDto }) {
|
function AccountRow({ account }: { account: TokenAccountDto }) {
|
||||||
const label = account.symbol ?? account.name ?? truncate(account.mint);
|
const label = account.symbol ?? account.name ?? truncate(account.mint);
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +129,7 @@ function AccountRow({ account }: { account: TokenAccountDto }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="account-row__balance">{account.uiBalance}</span>
|
<span className="account-row__balance">{account.uiBalance}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{account.sell && <SellInfoBlock sell={account.sell} />}
|
||||||
{account.warnings.length > 0 && (
|
{account.warnings.length > 0 && (
|
||||||
<ul className="account-row__warnings">
|
<ul className="account-row__warnings">
|
||||||
{account.warnings.map((w, i) => (
|
{account.warnings.map((w, i) => (
|
||||||
@@ -194,6 +252,8 @@ export function Scanner() {
|
|||||||
if (accounts.length === 0) return null;
|
if (accounts.length === 0) return null;
|
||||||
const isCloseable =
|
const isCloseable =
|
||||||
classification === TokenClassification.EMPTY_CLOSE_ONLY;
|
classification === TokenClassification.EMPTY_CLOSE_ONLY;
|
||||||
|
const isTransmutable =
|
||||||
|
classification === TokenClassification.TRANSMUTABLE;
|
||||||
return (
|
return (
|
||||||
<div key={classification} className="result-section">
|
<div key={classification} className="result-section">
|
||||||
<h3 className="result-section__heading">
|
<h3 className="result-section__heading">
|
||||||
@@ -216,6 +276,24 @@ export function Scanner() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{isTransmutable && (
|
||||||
|
<div className="transmute">
|
||||||
|
<p className="transmute__explainer">
|
||||||
|
Sellable scraps can be swapped to SOL to feed the PYRE
|
||||||
|
(coming next) — proceeds stay in YOUR wallet; recording
|
||||||
|
Essence is opt-in.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scan-btn transmute__cta"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
title="Coming next — swap signing is not wired yet."
|
||||||
|
>
|
||||||
|
Sell & feed the PYRE (soon)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
> dust. PYRE supports Token-2022 conservatively, gating on account/mint
|
> dust. PYRE supports Token-2022 conservatively, gating on account/mint
|
||||||
> **extensions** (see §7.1). The original brief's "skip Token-2022" stance is
|
> **extensions** (see §7.1). The original brief's "skip Token-2022" stance is
|
||||||
> superseded by this revision.
|
> superseded by this revision.
|
||||||
|
>
|
||||||
|
> **Revision note — Rev 3 (2026-05-31):** The core loop is *sell sellable tokens
|
||||||
|
> → feed the PYRE (opt-in Essence) → close the emptied account*; **burning is for
|
||||||
|
> unsellable tokens only**. Selling (TRANSMUTABLE) is pulled into near-term scope
|
||||||
|
> via the **Jupiter** aggregator (§6.1) — PYRE builds no swap math and runs no
|
||||||
|
> pump.fun engine. Essence is **model 1**: net SOL stays in the user's wallet and
|
||||||
|
> is recorded as an opt-in off-chain tally; **no custody** until the v1.0 program.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -144,10 +151,16 @@ account/mint extensions are safe to act on (see §7.1). Token-2022 accounts with
|
|||||||
unhandled extensions (confidential transfer, withheld transfer fees, unknown
|
unhandled extensions (confidential transfer, withheld transfer fees, unknown
|
||||||
extensions) and all frozen accounts are skipped.
|
extensions) and all frozen accounts are skipped.
|
||||||
|
|
||||||
**v0.1 must NOT include:** automatic Pump.fun launch, user-contributed Essence
|
**Selling scraps (Transmute) is now near-term scope** (Rev 3 — see §6.1): the core
|
||||||
vault, custom PYRE Solana program, NFT handling, automatic valuable-token
|
loop is *sell sellable tokens for SOL → feed the PYRE (opt-in Essence) → close the
|
||||||
sacrifice, custodial signing, background wallet automation, on-chain swap routing
|
emptied account*; burning is reserved for genuinely unsellable tokens. Selling is
|
||||||
(TRANSMUTABLE), or any Token-2022 confidential-transfer / fee-harvest flows.
|
routed through Jupiter (third-party aggregator); PYRE never builds the swap math
|
||||||
|
itself and never takes custody (Essence is an opt-in off-chain tally — model 1).
|
||||||
|
|
||||||
|
**v0.1 must NOT include:** automatic Pump.fun launch, custom PYRE Solana program,
|
||||||
|
NFT handling, automatic valuable-token sacrifice, custodial signing, background
|
||||||
|
wallet automation, any Essence VAULT/custody (no "deposit" until the v1.0 on-chain
|
||||||
|
program), or any Token-2022 confidential-transfer / fee-harvest flows.
|
||||||
|
|
||||||
### MVP v0.2 — Prometheus Meta Mixer
|
### MVP v0.2 — Prometheus Meta Mixer
|
||||||
AI generation from burned/cleaned token context.
|
AI generation from burned/cleaned token context.
|
||||||
@@ -194,9 +207,12 @@ Token accounts are classified into conservative categories.
|
|||||||
- **INCINERATE_ONLY** — no safe swap route but may be burnable. *Action:* user
|
- **INCINERATE_ONLY** — no safe swap route but may be burnable. *Action:* user
|
||||||
may burn balance to zero; if account becomes empty, close it; recovered rent
|
may burn balance to zero; if account becomes empty, close it; recovered rent
|
||||||
returns to user.
|
returns to user.
|
||||||
- **TRANSMUTABLE** — has a safe swap route and passes risk checks. *Action:* user
|
- **TRANSMUTABLE** — has a safe swap route (via Jupiter, §6.1) that passes the
|
||||||
may swap token into SOL; net swapped SOL may become Essence **only if the user
|
price-impact / slippage / dust guards. *Action:* user may swap the token into
|
||||||
opts in**.
|
SOL; the net SOL stays in the user's wallet, and may be recorded as Essence
|
||||||
|
("feed the PYRE") **only if the user opts in** (off-chain tally, no custody).
|
||||||
|
This is the preferred outcome for any token with real liquidity — burning is
|
||||||
|
for unsellable tokens only.
|
||||||
- **PROTECTED_SKIP** — not touched by default. Examples: SOL/WSOL special cases,
|
- **PROTECTED_SKIP** — not touched by default. Examples: SOL/WSOL special cases,
|
||||||
USDC/USDT/major assets, valuable meme tokens, NFTs, LP tokens, receipt tokens,
|
USDC/USDT/major assets, valuable meme tokens, NFTs, LP tokens, receipt tokens,
|
||||||
staked tokens, suspicious tokens, frozen accounts, delegated accounts,
|
staked tokens, suspicious tokens, frozen accounts, delegated accounts,
|
||||||
@@ -217,6 +233,43 @@ excluded from any future swap.
|
|||||||
> **Default rule: Unknown means skip** — unknown token program *or* unknown/unsafe
|
> **Default rule: Unknown means skip** — unknown token program *or* unknown/unsafe
|
||||||
> Token-2022 extension.
|
> Token-2022 extension.
|
||||||
|
|
||||||
|
### 6.1 Selling scraps (Transmute) — Jupiter + the third-party-swap trust model
|
||||||
|
|
||||||
|
A non-empty token is **TRANSMUTABLE** (preferred over burning) when it has a safe
|
||||||
|
route to SOL. PYRE does **not** implement swap math or run a pump.fun engine — it
|
||||||
|
uses **Jupiter** as the aggregator:
|
||||||
|
|
||||||
|
- **Routing:** Jupiter **Ultra** (`/ultra/v1/order` → user signs → `/ultra/v1/execute`,
|
||||||
|
keyless via `lite-api.jup.ag`) routes both **pre- and post-"graduation" pump.fun**
|
||||||
|
tokens and **Token-2022**. If Ultra returns no route for a mint, optionally fall
|
||||||
|
back to **PumpPortal**'s keyless local-trade API (bonding-curve sell); if neither
|
||||||
|
routes, the token is unsellable → INCINERATE_ONLY or close-for-rent.
|
||||||
|
- **Output:** sell to wSOL with `wrapAndUnwrapSol` so the user receives **native SOL**.
|
||||||
|
- **Risk pre-screen:** call Jupiter's **Shield** API per mint; surface
|
||||||
|
freeze/mint-authority and low-liquidity warnings.
|
||||||
|
- **Guards (a route alone is not enough):** block if `priceImpactPct` exceeds the
|
||||||
|
threshold (warn ~2–3%, hard cap ~10%); cap slippage; and a **dust gate** — if the
|
||||||
|
estimated NET SOL ≤ (tx fee + reclaimable rent), selling is not worth it → keep it
|
||||||
|
INCINERATE_ONLY / suggest close-for-rent instead.
|
||||||
|
|
||||||
|
**Trust model for a third-party-built swap tx (critical).** The swap transaction is
|
||||||
|
built by Jupiter, not PYRE, and is multi-instruction with address-lookup-tables —
|
||||||
|
so it **cannot be byte-matched** the way our own close/burn tx is (§16). Before the
|
||||||
|
user signs, PYRE must instead:
|
||||||
|
|
||||||
|
1. **Simulate** the transaction and confirm the user's **SOL balance delta ≥
|
||||||
|
`otherAmountThreshold`** (the quote's min-out) with no simulation error — this
|
||||||
|
validates the economic effect regardless of route structure.
|
||||||
|
2. Confirm the **only required signer is the user** (no co-signer / foreign fee payer).
|
||||||
|
3. Scan instructions and **reject any `SetAuthority`, delegate `Approve` to a foreign
|
||||||
|
address, or `CloseAccount` whose destination is not the user**.
|
||||||
|
4. Confirm proceeds credit the **user's** account.
|
||||||
|
|
||||||
|
The user always signs in their own wallet (PYRE never holds keys). **Essence
|
||||||
|
("feed the PYRE") = the net SOL is recorded as an opt-in, off-chain tally only;
|
||||||
|
proceeds stay in the user's wallet and PYRE takes no custody** until the v1.0
|
||||||
|
on-chain program. Rent and Essence are always kept separate (§3).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Token Safety Rules
|
## 7. Token Safety Rules
|
||||||
|
|||||||
@@ -147,10 +147,10 @@
|
|||||||
<section class="overall">
|
<section class="overall">
|
||||||
<div class="overall-head">
|
<div class="overall-head">
|
||||||
<h2>Overall MVP Progress</h2>
|
<h2>Overall MVP Progress</h2>
|
||||||
<span class="overall-pct">40%</span>
|
<span class="overall-pct">44%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar"><span style="width: 40%"></span></div>
|
<div class="bar"><span style="width: 44%"></span></div>
|
||||||
<p class="count">21 of 52 phase deliverables complete</p>
|
<p class="count">23 of 52 phase deliverables complete</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 class="section">Development Phases</h2>
|
<h2 class="section">Development Phases</h2>
|
||||||
@@ -247,15 +247,15 @@
|
|||||||
<li class="item"><span class="mark">○</span><span>Public Spawn record page</span></li>
|
<li class="item"><span class="mark">○</span><span>Public Spawn record page</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card todo">
|
<article class="card in_progress">
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h3><span class="phase-id">Phase 6</span> Essence / Round Prototype</h3>
|
<h3><span class="phase-id">Phase 6</span> Essence / Round Prototype</h3>
|
||||||
<span class="badge todo">TODO</span>
|
<span class="badge in_progress">IN PROGRESS</span>
|
||||||
</header>
|
</header>
|
||||||
<p class="count">0 / 6 complete</p>
|
<p class="count">2 / 6 complete</p>
|
||||||
<ul class="checklist">
|
<ul class="checklist">
|
||||||
<li class="item"><span class="mark">○</span><span>Safe swap candidate detection</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Safe swap candidate detection (Jupiter)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Route quote preview</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Route quote preview (price impact + dust gate + Shield)</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Net Essence estimate</span></li>
|
<li class="item"><span class="mark">○</span><span>Net Essence estimate</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Round dashboard</span></li>
|
<li class="item"><span class="mark">○</span><span>Round dashboard</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Contribution database record</span></li>
|
<li class="item"><span class="mark">○</span><span>Contribution database record</span></li>
|
||||||
|
|||||||
@@ -88,10 +88,10 @@
|
|||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"name": "Essence / Round Prototype",
|
"name": "Essence / Round Prototype",
|
||||||
"state": "todo",
|
"state": "in_progress",
|
||||||
"items": [
|
"items": [
|
||||||
{ "label": "Safe swap candidate detection", "done": false },
|
{ "label": "Safe swap candidate detection (Jupiter)", "done": true },
|
||||||
{ "label": "Route quote preview", "done": false },
|
{ "label": "Route quote preview (price impact + dust gate + Shield)", "done": true },
|
||||||
{ "label": "Net Essence estimate", "done": false },
|
{ "label": "Net Essence estimate", "done": false },
|
||||||
{ "label": "Round dashboard", "done": false },
|
{ "label": "Round dashboard", "done": false },
|
||||||
{ "label": "Contribution database record", "done": false },
|
{ "label": "Contribution database record", "done": false },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* scan/classify/build pipeline is implemented. Approximations are flagged inline.
|
* scan/classify/build pipeline is implemented. Approximations are flagged inline.
|
||||||
*/
|
*/
|
||||||
import type { TokenClassification } from "./classification";
|
import type { TokenClassification } from "./classification";
|
||||||
|
import type { SellInfo } from "./sell";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/scan
|
// POST /api/scan
|
||||||
@@ -58,6 +59,8 @@ export interface TokenAccountDto {
|
|||||||
frozen?: boolean;
|
frozen?: boolean;
|
||||||
/** Whether the account is delegated, if known. TODO: confirm availability. */
|
/** Whether the account is delegated, if known. TODO: confirm availability. */
|
||||||
delegated?: boolean;
|
delegated?: boolean;
|
||||||
|
/** Sell-quote (transmute) probe result for selling this token to SOL, for display. */
|
||||||
|
sell?: SellInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanResponse {
|
export interface ScanResponse {
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export * from "./extensions";
|
|||||||
export * from "./risk";
|
export * from "./risk";
|
||||||
export * from "./tx";
|
export * from "./tx";
|
||||||
export * from "./dto";
|
export * from "./dto";
|
||||||
|
export * from "./sell";
|
||||||
export * from "./receipt";
|
export * from "./receipt";
|
||||||
export * from "./prometheus";
|
export * from "./prometheus";
|
||||||
|
|||||||
13
packages/core/src/sell.ts
Normal file
13
packages/core/src/sell.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** Result of probing whether a token can be sold to SOL (via Jupiter), for display. */
|
||||||
|
export interface SellInfo {
|
||||||
|
/** True if a usable route to SOL was found within the guards. */
|
||||||
|
routable: boolean;
|
||||||
|
/** Estimated NET SOL out, in lamports (string, u64-safe). Present when routable. */
|
||||||
|
estimatedSolLamports?: string;
|
||||||
|
/** Quoted price impact, percent (e.g. 1.2 = 1.2%). Present when a quote was obtained. */
|
||||||
|
priceImpactPct?: number;
|
||||||
|
/** Jupiter Shield risk flags for the mint (e.g. "HAS_FREEZE_AUTHORITY"). */
|
||||||
|
riskFlags?: string[];
|
||||||
|
/** Human note when not routable / not worth selling (e.g. "no route", "dust: rent > sale value", "price impact too high"). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user