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

@@ -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>
);
})}