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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user