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:
2026-05-31 05:11:20 +00:00
parent 00f9a96286
commit f9c471ef71
10 changed files with 599 additions and 18 deletions

View File

@@ -31,15 +31,47 @@ import type {
ScanSummary,
TokenAccountDto,
ParsedTokenAccount,
SellInfo,
} from "@pyre/core";
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
import type {
BuildCloseEmptyResponse,
ReceiptResponse,
} from "@pyre/core";
import { getSellQuote, getShield } from "./jupiter.js";
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
* `@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). */
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.
const connection = new Connection(config.solanaRpcUrl, "confirmed");
@@ -187,6 +344,22 @@ app.post<{ Body: ScanBody }>(
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 = {
scanId: randomUUID(),
wallet: walletPk.toBase58(),

187
apps/api/src/jupiter.ts Normal file
View 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);
}
}

View File

@@ -449,6 +449,79 @@ body {
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 {
margin-top: 2rem;
padding: 0.85rem 1rem;

View File

@@ -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 310%, 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 }) {
const label = account.symbol ?? account.name ?? truncate(account.mint);
return (
@@ -72,6 +129,7 @@ function AccountRow({ account }: { account: TokenAccountDto }) {
</span>
<span className="account-row__balance">{account.uiBalance}</span>
</div>
{account.sell && <SellInfoBlock sell={account.sell} />}
{account.warnings.length > 0 && (
<ul className="account-row__warnings">
{account.warnings.map((w, i) => (
@@ -194,6 +252,8 @@ export function Scanner() {
if (accounts.length === 0) return null;
const isCloseable =
classification === TokenClassification.EMPTY_CLOSE_ONLY;
const isTransmutable =
classification === TokenClassification.TRANSMUTABLE;
return (
<div key={classification} className="result-section">
<h3 className="result-section__heading">
@@ -216,6 +276,24 @@ export function Scanner() {
))}
</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 &amp; feed the PYRE (soon)
</button>
</div>
)}
</div>
);
})}