diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4bf74e3..49f253c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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([ /** 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 { + // Overall budget: race the whole pass against a hard timeout so a slow + // Jupiter cannot inflate scan latency. + let timer: ReturnType | undefined; + const budget = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), SELL_ENRICH_TIMEOUT_MS); + }); + + const work = (async (): Promise => { + // 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(), diff --git a/apps/api/src/jupiter.ts b/apps/api/src/jupiter.ts new file mode 100644 index 0000000..05cc055 --- /dev/null +++ b/apps/api/src/jupiter.ts @@ -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; + 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> { + const result = new Map(); + 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: { : [{ type, ... }] } }` form plus a couple of + * tolerant fallbacks, and silently ignore anything we don't recognise. + */ +function mergeShieldBody(body: unknown, out: Map): void { + if (body === null || typeof body !== "object") return; + + // Shield returns warnings keyed by mint, typically under `warnings`. + const container = (body as Record)["warnings"] ?? body; + if (container === null || typeof container !== "object") return; + + for (const [mint, value] of Object.entries( + container as Record, + )) { + 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; + 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 { + 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); + } +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 1053a84..679916e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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; diff --git a/apps/web/src/components/Scanner.tsx b/apps/web/src/components/Scanner.tsx index bd605d0..11c1ee9 100644 --- a/apps/web/src/components/Scanner.tsx +++ b/apps/web/src/components/Scanner.tsx @@ -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 }) { + if (sell.routable) { + const sol = + sell.estimatedSolLamports != null + ? lamportsToSol(sell.estimatedSolLamports) + : null; + const tone = sell.priceImpactPct != null ? impactTone(sell.priceImpactPct) : null; + return ( +
+ {sol != null && ( + Sellable for ~{sol.toFixed(5)} SOL + )} + {sell.priceImpactPct != null && tone && ( + + price impact {sell.priceImpactPct.toFixed(2)}% + + )} + {sell.riskFlags && sell.riskFlags.length > 0 && ( + + {sell.riskFlags.map((f) => ( + + {humanizeRiskFlag(f)} + + ))} + + )} +
+ ); + } + // Not routable / dust / impact too high — show the note muted. + if (sell.note) { + return ( +
+ {sell.note} +
+ ); + } + 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 }) { {account.uiBalance} + {account.sell && } {account.warnings.length > 0 && (
    {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 (

    @@ -216,6 +276,24 @@ export function Scanner() { ))}

)} + {isTransmutable && ( +
+

+ Sellable scraps can be swapped to SOL to feed the PYRE + (coming next) — proceeds stay in YOUR wallet; recording + Essence is opt-in. +

+ +
+ )} ); })} diff --git a/docs/PYRE_MVP_DESIGN.md b/docs/PYRE_MVP_DESIGN.md index 438a7b3..5844420 100644 --- a/docs/PYRE_MVP_DESIGN.md +++ b/docs/PYRE_MVP_DESIGN.md @@ -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 ~2–3%, 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 diff --git a/infra/status/index.html b/infra/status/index.html index ed29913..c55ef19 100644 --- a/infra/status/index.html +++ b/infra/status/index.html @@ -147,10 +147,10 @@

Overall MVP Progress

- 40% + 44%
-
-

21 of 52 phase deliverables complete

+
+

23 of 52 phase deliverables complete

Development Phases

@@ -247,15 +247,15 @@
  • Public Spawn record page
  • -
    +

    Phase 6 Essence / Round Prototype

    - TODO + IN PROGRESS
    -

    0 / 6 complete

    +

    2 / 6 complete

      -
    • Safe swap candidate detection
    • -
    • Route quote preview
    • +
    • Safe swap candidate detection (Jupiter)
    • +
    • Route quote preview (price impact + dust gate + Shield)
    • Net Essence estimate
    • Round dashboard
    • Contribution database record
    • diff --git a/infra/status/status.json b/infra/status/status.json index db78913..252f225 100644 --- a/infra/status/status.json +++ b/infra/status/status.json @@ -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 }, diff --git a/packages/core/src/dto.ts b/packages/core/src/dto.ts index 2275a26..d5b0b6c 100644 --- a/packages/core/src/dto.ts +++ b/packages/core/src/dto.ts @@ -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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e33e82..1ff27ad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/sell.ts b/packages/core/src/sell.ts new file mode 100644 index 0000000..1cc522a --- /dev/null +++ b/packages/core/src/sell.ts @@ -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; +}