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,
|
||||
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
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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@@ -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 }) {
|
||||
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 & feed the PYRE (soon)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
> dust. PYRE supports Token-2022 conservatively, gating on account/mint
|
||||
> **extensions** (see §7.1). The original brief's "skip Token-2022" stance is
|
||||
> 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
|
||||
extensions) and all frozen accounts are skipped.
|
||||
|
||||
**v0.1 must NOT include:** automatic Pump.fun launch, user-contributed Essence
|
||||
vault, custom PYRE Solana program, NFT handling, automatic valuable-token
|
||||
sacrifice, custodial signing, background wallet automation, on-chain swap routing
|
||||
(TRANSMUTABLE), or any Token-2022 confidential-transfer / fee-harvest flows.
|
||||
**Selling scraps (Transmute) is now near-term scope** (Rev 3 — see §6.1): the core
|
||||
loop is *sell sellable tokens for SOL → feed the PYRE (opt-in Essence) → close the
|
||||
emptied account*; burning is reserved for genuinely unsellable tokens. Selling is
|
||||
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
|
||||
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
|
||||
may burn balance to zero; if account becomes empty, close it; recovered rent
|
||||
returns to user.
|
||||
- **TRANSMUTABLE** — has a safe swap route and passes risk checks. *Action:* user
|
||||
may swap token into SOL; net swapped SOL may become Essence **only if the user
|
||||
opts in**.
|
||||
- **TRANSMUTABLE** — has a safe swap route (via Jupiter, §6.1) that passes the
|
||||
price-impact / slippage / dust guards. *Action:* user may swap the token into
|
||||
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,
|
||||
USDC/USDT/major assets, valuable meme tokens, NFTs, LP tokens, receipt tokens,
|
||||
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
|
||||
> 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
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
<section class="overall">
|
||||
<div class="overall-head">
|
||||
<h2>Overall MVP Progress</h2>
|
||||
<span class="overall-pct">40%</span>
|
||||
<span class="overall-pct">44%</span>
|
||||
</div>
|
||||
<div class="bar"><span style="width: 40%"></span></div>
|
||||
<p class="count">21 of 52 phase deliverables complete</p>
|
||||
<div class="bar"><span style="width: 44%"></span></div>
|
||||
<p class="count">23 of 52 phase deliverables complete</p>
|
||||
</section>
|
||||
|
||||
<h2 class="section">Development Phases</h2>
|
||||
@@ -247,15 +247,15 @@
|
||||
<li class="item"><span class="mark">○</span><span>Public Spawn record page</span></li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="card todo">
|
||||
<article class="card in_progress">
|
||||
<header class="card-head">
|
||||
<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>
|
||||
<p class="count">0 / 6 complete</p>
|
||||
<p class="count">2 / 6 complete</p>
|
||||
<ul class="checklist">
|
||||
<li class="item"><span class="mark">○</span><span>Safe swap candidate detection</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Route quote preview</span></li>
|
||||
<li class="item done"><span class="mark">✓</span><span>Safe swap candidate detection (Jupiter)</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>Round dashboard</span></li>
|
||||
<li class="item"><span class="mark">○</span><span>Contribution database record</span></li>
|
||||
|
||||
@@ -88,10 +88,10 @@
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Essence / Round Prototype",
|
||||
"state": "todo",
|
||||
"state": "in_progress",
|
||||
"items": [
|
||||
{ "label": "Safe swap candidate detection", "done": false },
|
||||
{ "label": "Route quote preview", "done": false },
|
||||
{ "label": "Safe swap candidate detection (Jupiter)", "done": true },
|
||||
{ "label": "Route quote preview (price impact + dust gate + Shield)", "done": true },
|
||||
{ "label": "Net Essence estimate", "done": false },
|
||||
{ "label": "Round dashboard", "done": false },
|
||||
{ "label": "Contribution database record", "done": false },
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* scan/classify/build pipeline is implemented. Approximations are flagged inline.
|
||||
*/
|
||||
import type { TokenClassification } from "./classification";
|
||||
import type { SellInfo } from "./sell";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/scan
|
||||
@@ -58,6 +59,8 @@ export interface TokenAccountDto {
|
||||
frozen?: boolean;
|
||||
/** Whether the account is delegated, if known. TODO: confirm availability. */
|
||||
delegated?: boolean;
|
||||
/** Sell-quote (transmute) probe result for selling this token to SOL, for display. */
|
||||
sell?: SellInfo;
|
||||
}
|
||||
|
||||
export interface ScanResponse {
|
||||
|
||||
@@ -5,5 +5,6 @@ export * from "./extensions";
|
||||
export * from "./risk";
|
||||
export * from "./tx";
|
||||
export * from "./dto";
|
||||
export * from "./sell";
|
||||
export * from "./receipt";
|
||||
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