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, ScanSummary,
TokenAccountDto, TokenAccountDto,
ParsedTokenAccount, ParsedTokenAccount,
SellInfo,
} from "@pyre/core"; } from "@pyre/core";
import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana"; import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana";
import type { import type {
BuildCloseEmptyResponse, BuildCloseEmptyResponse,
ReceiptResponse, ReceiptResponse,
} from "@pyre/core"; } from "@pyre/core";
import { getSellQuote, getShield } from "./jupiter.js";
const config = loadConfig(); 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 * 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 * `@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). */ /** SPL Token `CloseAccount` instruction discriminator (first data byte). */
const CLOSE_ACCOUNT_IX = 9; 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. // External RPC provider only — never run a validator/RPC node on the MVP VPS.
const connection = new Connection(config.solanaRpcUrl, "confirmed"); const connection = new Connection(config.solanaRpcUrl, "confirmed");
@@ -187,6 +344,22 @@ app.post<{ Body: ScanBody }>(
summary.estimatedRentLamports = rentSum.toString(); 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 = { const response: ScanResponse = {
scanId: randomUUID(), scanId: randomUUID(),
wallet: walletPk.toBase58(), 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; 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 { .preview-note {
margin-top: 2rem; margin-top: 2rem;
padding: 0.85rem 1rem; 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 }) { function AccountRow({ account }: { account: TokenAccountDto }) {
const label = account.symbol ?? account.name ?? truncate(account.mint); const label = account.symbol ?? account.name ?? truncate(account.mint);
return ( return (
@@ -72,6 +129,7 @@ function AccountRow({ account }: { account: TokenAccountDto }) {
</span> </span>
<span className="account-row__balance">{account.uiBalance}</span> <span className="account-row__balance">{account.uiBalance}</span>
</div> </div>
{account.sell && <SellInfoBlock sell={account.sell} />}
{account.warnings.length > 0 && ( {account.warnings.length > 0 && (
<ul className="account-row__warnings"> <ul className="account-row__warnings">
{account.warnings.map((w, i) => ( {account.warnings.map((w, i) => (
@@ -194,6 +252,8 @@ export function Scanner() {
if (accounts.length === 0) return null; if (accounts.length === 0) return null;
const isCloseable = const isCloseable =
classification === TokenClassification.EMPTY_CLOSE_ONLY; classification === TokenClassification.EMPTY_CLOSE_ONLY;
const isTransmutable =
classification === TokenClassification.TRANSMUTABLE;
return ( return (
<div key={classification} className="result-section"> <div key={classification} className="result-section">
<h3 className="result-section__heading"> <h3 className="result-section__heading">
@@ -216,6 +276,24 @@ export function Scanner() {
))} ))}
</ul> </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> </div>
); );
})} })}

View File

@@ -11,6 +11,13 @@
> dust. PYRE supports Token-2022 conservatively, gating on account/mint > dust. PYRE supports Token-2022 conservatively, gating on account/mint
> **extensions** (see §7.1). The original brief's "skip Token-2022" stance is > **extensions** (see §7.1). The original brief's "skip Token-2022" stance is
> superseded by this revision. > 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 unhandled extensions (confidential transfer, withheld transfer fees, unknown
extensions) and all frozen accounts are skipped. extensions) and all frozen accounts are skipped.
**v0.1 must NOT include:** automatic Pump.fun launch, user-contributed Essence **Selling scraps (Transmute) is now near-term scope** (Rev 3 — see §6.1): the core
vault, custom PYRE Solana program, NFT handling, automatic valuable-token loop is *sell sellable tokens for SOL → feed the PYRE (opt-in Essence) → close the
sacrifice, custodial signing, background wallet automation, on-chain swap routing emptied account*; burning is reserved for genuinely unsellable tokens. Selling is
(TRANSMUTABLE), or any Token-2022 confidential-transfer / fee-harvest flows. 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 ### MVP v0.2 — Prometheus Meta Mixer
AI generation from burned/cleaned token context. 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 - **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 may burn balance to zero; if account becomes empty, close it; recovered rent
returns to user. returns to user.
- **TRANSMUTABLE** — has a safe swap route and passes risk checks. *Action:* user - **TRANSMUTABLE** — has a safe swap route (via Jupiter, §6.1) that passes the
may swap token into SOL; net swapped SOL may become Essence **only if the user price-impact / slippage / dust guards. *Action:* user may swap the token into
opts in**. 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, - **PROTECTED_SKIP** — not touched by default. Examples: SOL/WSOL special cases,
USDC/USDT/major assets, valuable meme tokens, NFTs, LP tokens, receipt tokens, USDC/USDT/major assets, valuable meme tokens, NFTs, LP tokens, receipt tokens,
staked tokens, suspicious tokens, frozen accounts, delegated accounts, 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 > **Default rule: Unknown means skip** — unknown token program *or* unknown/unsafe
> Token-2022 extension. > 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 ~23%, 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 ## 7. Token Safety Rules

View File

@@ -147,10 +147,10 @@
<section class="overall"> <section class="overall">
<div class="overall-head"> <div class="overall-head">
<h2>Overall MVP Progress</h2> <h2>Overall MVP Progress</h2>
<span class="overall-pct">40%</span> <span class="overall-pct">44%</span>
</div> </div>
<div class="bar"><span style="width: 40%"></span></div> <div class="bar"><span style="width: 44%"></span></div>
<p class="count">21 of 52 phase deliverables complete</p> <p class="count">23 of 52 phase deliverables complete</p>
</section> </section>
<h2 class="section">Development Phases</h2> <h2 class="section">Development Phases</h2>
@@ -247,15 +247,15 @@
<li class="item"><span class="mark"></span><span>Public Spawn record page</span></li> <li class="item"><span class="mark"></span><span>Public Spawn record page</span></li>
</ul> </ul>
</article> </article>
<article class="card todo"> <article class="card in_progress">
<header class="card-head"> <header class="card-head">
<h3><span class="phase-id">Phase 6</span> Essence / Round Prototype</h3> <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> </header>
<p class="count">0 / 6 complete</p> <p class="count">2 / 6 complete</p>
<ul class="checklist"> <ul class="checklist">
<li class="item"><span class="mark"></span><span>Safe swap candidate detection</span></li> <li class="item done"><span class="mark"></span><span>Safe swap candidate detection (Jupiter)</span></li>
<li class="item"><span class="mark"></span><span>Route quote preview</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>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>Round dashboard</span></li>
<li class="item"><span class="mark"></span><span>Contribution database record</span></li> <li class="item"><span class="mark"></span><span>Contribution database record</span></li>

View File

@@ -88,10 +88,10 @@
{ {
"id": 6, "id": 6,
"name": "Essence / Round Prototype", "name": "Essence / Round Prototype",
"state": "todo", "state": "in_progress",
"items": [ "items": [
{ "label": "Safe swap candidate detection", "done": false }, { "label": "Safe swap candidate detection (Jupiter)", "done": true },
{ "label": "Route quote preview", "done": false }, { "label": "Route quote preview (price impact + dust gate + Shield)", "done": true },
{ "label": "Net Essence estimate", "done": false }, { "label": "Net Essence estimate", "done": false },
{ "label": "Round dashboard", "done": false }, { "label": "Round dashboard", "done": false },
{ "label": "Contribution database record", "done": false }, { "label": "Contribution database record", "done": false },

View File

@@ -10,6 +10,7 @@
* scan/classify/build pipeline is implemented. Approximations are flagged inline. * scan/classify/build pipeline is implemented. Approximations are flagged inline.
*/ */
import type { TokenClassification } from "./classification"; import type { TokenClassification } from "./classification";
import type { SellInfo } from "./sell";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /api/scan // POST /api/scan
@@ -58,6 +59,8 @@ export interface TokenAccountDto {
frozen?: boolean; frozen?: boolean;
/** Whether the account is delegated, if known. TODO: confirm availability. */ /** Whether the account is delegated, if known. TODO: confirm availability. */
delegated?: boolean; delegated?: boolean;
/** Sell-quote (transmute) probe result for selling this token to SOL, for display. */
sell?: SellInfo;
} }
export interface ScanResponse { export interface ScanResponse {

View File

@@ -5,5 +5,6 @@ export * from "./extensions";
export * from "./risk"; export * from "./risk";
export * from "./tx"; export * from "./tx";
export * from "./dto"; export * from "./dto";
export * from "./sell";
export * from "./receipt"; export * from "./receipt";
export * from "./prometheus"; export * from "./prometheus";

13
packages/core/src/sell.ts Normal file
View 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;
}