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:
@@ -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