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,
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
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;
}
/* 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>
);
})}

View File

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

View File

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

View File

@@ -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 },

View File

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

View File

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