diff --git a/.env.example b/.env.example index d87130f..89b6642 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,14 @@ PROTECTED_USD_THRESHOLD=50 # skip tokens valued above this (USD) MAX_PRICE_IMPACT_BPS=300 # skip swap routes above this impact QUOTE_MAX_AGE_MS=15000 # skip stale quotes older than this +# ---- Protocol fee (§3.1) — transparent, in-tx, non-custodial --------------- +# The treasury receives ONLY the fee SOL (never user funds). Swap it for a +# multisig before real volume. The fee is shown in the preview before signing. +PYRE_TREASURY_WALLET=122CNV5ZLu6fqZFpEMUdUSQwDv2zs23pkYQhkNtSQk5k +PYRE_FEE_BPS=500 # 5% of reclaimed rent +PYRE_SWAP_FEE_BPS=100 # 1% on swaps (proceeds still go to user) +PYRE_MAX_CONTRIBUTION_BPS=5000 # cap on the optional "feed more" extra (50%) + # ---- Optional: metadata / launch (later phases) ---------------------------- IPFS_OR_ARWEAVE_ENDPOINT= IPFS_OR_ARWEAVE_TOKEN= diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 49f253c..67da3b4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -33,11 +33,23 @@ import type { ParsedTokenAccount, SellInfo, } from "@pyre/core"; -import { parseTokenAccounts, buildCloseEmptyAccountsTx } from "@pyre/solana"; +import { + parseTokenAccounts, + buildCloseEmptyAccountsTx, + buildBurnTx, +} from "@pyre/solana"; import type { BuildCloseEmptyResponse, + BuildBurnResponse, + BurnItem, ReceiptResponse, } from "@pyre/core"; +import { + migrate, + recordReceipt, + recordEssence, + getEssenceSummary, +} from "@pyre/db"; import { getSellQuote, getShield } from "./jupiter.js"; const config = loadConfig(); @@ -85,6 +97,15 @@ const TOKEN_PROGRAM_IDS = new Set([ /** SPL Token `CloseAccount` instruction discriminator (first data byte). */ const CLOSE_ACCOUNT_IX = 9; +/** SPL Token `Burn` instruction discriminator (first data byte). */ +const BURN_IX = 8; + +/** System program id (base58) — source of SOL transfers (e.g. the fee to treasury). */ +const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111"; + +/** System program `Transfer` instruction discriminator (first 4 data bytes, u32 LE). */ +const SYSTEM_TRANSFER_IX = 2; + /** * 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 @@ -394,12 +415,21 @@ const buildCloseEmptyBodySchema = { maxItems: 30, items: { type: "string", minLength: 32, maxLength: 44 }, }, + // Optional "feed the PYRE" contribution, basis points. Bounded at the + // operator-configured max so the client can never request more than the + // protocol allows; the builder re-clamps server-side as defense in depth. + contributionBps: { + type: "integer", + minimum: 0, + maximum: config.maxContributionBps, + }, }, } as const; interface BuildCloseEmptyBody { wallet: string; accountAddresses: string[]; + contributionBps?: number; } /** @@ -420,7 +450,7 @@ app.post<{ Body: BuildCloseEmptyBody }>( config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } }, }, async (request, reply) => { - const { wallet, accountAddresses } = request.body; + const { wallet, accountAddresses, contributionBps } = request.body; // Validate the wallet pubkey (base58) — never trust client input. let walletPk: PublicKey; @@ -450,7 +480,16 @@ app.post<{ Body: BuildCloseEmptyBody }>( let built: BuildCloseEmptyResponse; try { - built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks); + // Thread the transparent protocol fee + optional contribution through the + // builder. The treasury + base fee come from operator config (never the + // client); contributionBps is schema-bounded above and re-clamped by the + // builder against maxContributionBps. + built = await buildCloseEmptyAccountsTx(connection, walletPk, accountPks, { + feeBps: config.feeBps, + treasury: config.feeTreasury, + contributionBps, + maxContributionBps: config.maxContributionBps, + }); } catch (err) { // The builder throws when any account is ineligible (its message lists // which/why). Surface as a 400 — do NOT build an unsafe close. @@ -467,6 +506,137 @@ app.post<{ Body: BuildCloseEmptyBody }>( }, ); +// =========================================================================== +// POST /api/build/burn — build an UNSIGNED burn(+close) transaction. +// =========================================================================== + +/** + * Request body schema for POST /api/build/burn. + * + * `additionalProperties:false` so the client cannot smuggle a fee destination, + * classification, or extra contribution beyond the configured cap. The builder + * recomputes eligibility + the fee server-side (§16); the only client-supplied + * burn amounts are the per-item `amount` strings, which the builder validates + * against the live on-chain balance. + */ +const buildBurnBodySchema = { + type: "object", + required: ["wallet", "items"], + additionalProperties: false, + properties: { + wallet: { type: "string", minLength: 32, maxLength: 44 }, + items: { + type: "array", + minItems: 1, + maxItems: 30, + items: { + type: "object", + required: ["tokenAccount", "mint", "amount"], + additionalProperties: false, + properties: { + tokenAccount: { type: "string", minLength: 32, maxLength: 44 }, + mint: { type: "string", minLength: 32, maxLength: 44 }, + amount: { type: "string" }, + }, + }, + }, + // Optional "feed the PYRE" contribution, basis points; schema-bounded at the + // configured max and re-clamped server-side by the builder. + contributionBps: { + type: "integer", + minimum: 0, + maximum: config.maxContributionBps, + }, + }, +} as const; + +interface BuildBurnBody { + wallet: string; + items: BurnItem[]; + contributionBps?: number; +} + +/** + * POST /api/build/burn — build an UNSIGNED burn(+close) transaction. + * + * in: { wallet, items[], contributionBps? } + * out: { transactionBase64, preview } (BuildBurnResponse) + * + * PYRE holds no keys (§3): this returns an unsigned transaction the client + * decodes, previews, and signs in its own wallet. The builder THROWS on any + * ineligible item (not owned / wrong program / protected / amount mismatch); + * that surfaces as a 400 so the client cannot coerce a burn of something unsafe. + * Never signs. + */ +app.post<{ Body: BuildBurnBody }>( + "/api/build/burn", + { + schema: { body: buildBurnBodySchema }, + config: { rateLimit: { max: config.rateLimitScanPerMin, timeWindow: "1 minute" } }, + }, + async (request, reply) => { + const { wallet, items, contributionBps } = request.body; + + // Validate the wallet pubkey (base58) — never trust client input. + let walletPk: PublicKey; + try { + walletPk = new PublicKey(wallet); + } catch { + return reply.code(400).send({ error: "invalid wallet address" }); + } + + // Validate every item's tokenAccount + mint pubkey; report the offender. + for (const item of items) { + try { + new PublicKey(item.tokenAccount); + } catch { + return reply + .code(400) + .send({ error: "invalid token account address", detail: item.tokenAccount }); + } + try { + new PublicKey(item.mint); + } catch { + return reply + .code(400) + .send({ error: "invalid mint address", detail: item.mint }); + } + } + + // Log the tx-build request (wallet + item count only) per §16. + request.log.info( + { wallet: walletPk.toBase58(), itemCount: items.length }, + "build burn request", + ); + + let built: BuildBurnResponse; + try { + // Thread the transparent protocol fee + optional contribution through the + // builder. Treasury + base fee come from operator config (never the + // client); contributionBps is schema-bounded above and re-clamped by the + // builder against maxContributionBps. + built = await buildBurnTx(connection, walletPk, items, { + feeBps: config.feeBps, + treasury: config.feeTreasury, + contributionBps, + maxContributionBps: config.maxContributionBps, + }); + } catch (err) { + // The builder throws when any item is ineligible (its message lists + // which/why). Surface as a 400 — do NOT build an unsafe burn. + const detail = err instanceof Error ? err.message : String(err); + request.log.warn( + { wallet: walletPk.toBase58(), detail }, + "burn build rejected ineligible accounts", + ); + return reply.code(400).send({ error: "ineligible accounts", detail }); + } + + // PYRE never signs (§3) — return the unsigned tx + preview verbatim. + return built; + }, +); + // =========================================================================== // POST /api/receipt — verify a confirmed close tx ON-CHAIN and emit a receipt. // =========================================================================== @@ -567,14 +737,14 @@ function deriveClosedAccounts( return closed; } -/** Decode the first byte of a base58-encoded legacy instruction `data` field. */ -function decodeFirstByte(data: string): number | undefined { - const ALPHABET = - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - // Base58-decode just enough to read the leading byte. +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/** Base58-decode a string to bytes, or `undefined` on any invalid character. */ +function base58Decode(data: string): Uint8Array | undefined { let num = 0n; for (const ch of data) { - const idx = ALPHABET.indexOf(ch); + const idx = BASE58_ALPHABET.indexOf(ch); if (idx < 0) return undefined; num = num * 58n + BigInt(idx); } @@ -589,7 +759,116 @@ function decodeFirstByte(data: string): number | undefined { num >>= 8n; } for (let i = 0; i < leadingZeros; i++) bytes.unshift(0); - return bytes[0]; + return Uint8Array.from(bytes); +} + +/** Decode the first byte of a base58-encoded legacy instruction `data` field. */ +function decodeFirstByte(data: string): number | undefined { + const bytes = base58Decode(data); + return bytes?.[0]; +} + +/** + * Normalize a (legacy or versioned) compiled instruction into a uniform shape: + * the resolved program id (base58) + raw data bytes + the account-key indexes. + * Returns `undefined` for any instruction we can't decode. + */ +function normalizeInstruction( + ix: + | { programIdIndex: number; accountKeyIndexes: number[]; data: Uint8Array } + | { programIdIndex: number; accounts: number[]; data: string | Uint8Array }, + keys: string[], +): { programId: string | undefined; data: Uint8Array; accounts: number[] } | undefined { + const programId = keys[ix.programIdIndex]; + let data: Uint8Array | undefined; + let accounts: number[]; + if ("accountKeyIndexes" in ix) { + data = ix.data; + accounts = ix.accountKeyIndexes; + } else { + data = ix.data instanceof Uint8Array ? ix.data : base58Decode(ix.data); + accounts = ix.accounts; + } + if (data === undefined) return undefined; + return { programId, data, accounts }; +} + +/** Iterate the compiled instructions of a (legacy or versioned) message. */ +function* iterInstructions(message: { + compiledInstructions?: { + programIdIndex: number; + accountKeyIndexes: number[]; + data: Uint8Array; + }[]; + instructions?: { + programIdIndex: number; + accounts: number[]; + data: string | Uint8Array; + }[]; +}): Generator< + | { programIdIndex: number; accountKeyIndexes: number[]; data: Uint8Array } + | { programIdIndex: number; accounts: number[]; data: string | Uint8Array } +> { + if (message.compiledInstructions) { + yield* message.compiledInstructions; + return; + } + yield* message.instructions ?? []; +} + +/** + * True if the confirmed transaction contains any SPL Token / Token-2022 `Burn` + * instruction (discriminator 8). Used to classify the receipt as 'burn'. + */ +function txHasBurn( + message: Parameters[0], + keys: string[], +): boolean { + for (const raw of iterInstructions(message)) { + const ix = normalizeInstruction(raw, keys); + if (ix === undefined) continue; + if (ix.programId === undefined || !TOKEN_PROGRAM_IDS.has(ix.programId)) continue; + if (ix.data.length > 0 && ix.data[0] === BURN_IX) return true; + } + return false; +} + +/** + * Sum the lamports of every System-program Transfer whose recipient is the fee + * treasury, read straight from the confirmed transaction's instructions. The + * transfer's destination is the instruction's SECOND account index; the amount + * is a little-endian u64 at data bytes 4..12 (after the 4-byte u32 discriminator). + * Returns the total as a decimal string ("0" if none). NEVER trust the client + * for this — it is read from on-chain truth. + */ +function deriveTreasuryFee( + message: Parameters[0], + keys: string[], + treasuryBase58: string, +): string { + let total = 0n; + for (const raw of iterInstructions(message)) { + const ix = normalizeInstruction(raw, keys); + if (ix === undefined) continue; + if (ix.programId !== SYSTEM_PROGRAM_ID) continue; + if (ix.data.length < 12) continue; + // First 4 data bytes = u32 LE instruction discriminator (Transfer === 2). + const disc = + ix.data[0]! | + (ix.data[1]! << 8) | + (ix.data[2]! << 16) | + (ix.data[3]! << 24); + if (disc !== SYSTEM_TRANSFER_IX) continue; + const destIdx = ix.accounts[1]; + if (destIdx === undefined || keys[destIdx] !== treasuryBase58) continue; + // u64 LE lamports at bytes 4..12. + let lamports = 0n; + for (let b = 0; b < 8; b++) { + lamports |= BigInt(ix.data[4 + b]!) << BigInt(8 * b); + } + total += lamports; + } + return total.toString(); } /** @@ -602,9 +881,9 @@ function decodeFirstByte(data: string): number | undefined { * is only a lookup key + signer assertion. Rent returned is computed from the * fee payer's balance delta plus the fee, never trusted from the request. * - * DB persistence remains DEFERRED — the receipt is computed and returned live; - * no receipts row is written to @pyre/db yet (the receiptId is a fresh UUID - * with no persisted row behind it). + * The receipt is computed from on-chain truth and returned live; it is ALSO + * best-effort persisted to @pyre/db (cleanup_receipts) and the treasury fee is + * recorded as Essence — a DB failure never fails the receipt response. */ app.post<{ Body: ReceiptBody }>( "/api/receipt", @@ -679,6 +958,47 @@ app.post<{ Body: ReceiptBody }>( if (rentReturned < 0n) rentReturned = 0n; } + // ----------------------------------------------------------------------- + // Persist + Essence ledger (best-effort, NEVER affects the response). + // + // Both the fee that reached the treasury and whether this was a burn are + // derived from the SAME confirmed tx — the client is never trusted for them. + // ----------------------------------------------------------------------- + const feeLamports = deriveTreasuryFee( + tx.transaction.message, + keys, + config.feeTreasury, + ); + const kind: "close" | "burn" = txHasBurn(tx.transaction.message, keys) + ? "burn" + : "close"; + + try { + await recordReceipt({ + wallet: walletPk.toBase58(), + txSignature, + kind, + rentReturnedLamports: rentReturned.toString(), + feeLamports, + closedAccounts, + }); + if (feeLamports !== "0") { + await recordEssence({ + wallet: walletPk.toBase58(), + txSignature, + lamports: feeLamports, + kind: "fee", + }); + } + } catch (err) { + // Persistence is best-effort: a DB outage must not fail a receipt that is + // already true on-chain. Log and continue. + request.log.warn( + { err, txSignature, wallet: walletPk.toBase58() }, + "receipt persistence failed (best-effort)", + ); + } + const response: ReceiptResponse = { receiptId: randomUUID(), txSignature, @@ -693,6 +1013,43 @@ app.post<{ Body: ReceiptBody }>( }, ); +// =========================================================================== +// GET /api/essence — public, read-only round + Essence ledger summary. +// =========================================================================== + +/** + * GET /api/essence — current round's Essence summary. + * + * out: { roundId, totalLamports, contributionCount, recent } + * + * Read-only and public. Degrades gracefully: on any DB error it returns an + * empty summary (200) rather than failing, so the UI always has something to + * render even when persistence is down. + */ +app.get("/api/essence", async (request) => { + try { + return await getEssenceSummary(); + } catch (err) { + request.log.warn({ err }, "essence summary unavailable (DB error)"); + return { + roundId: null, + totalLamports: "0", + contributionCount: 0, + recent: [], + }; + } +}); + +// Run DB migrations at startup. Best-effort: if the database is unreachable we +// log a warning and keep serving — persistence (receipts + Essence ledger) +// simply degrades to a no-op until the DB returns, rather than crashing the API. +try { + await migrate(); + app.log.info("@pyre/db migrations applied"); +} catch (err) { + app.log.warn({ err }, "@pyre/db migrations failed — persistence is best-effort"); +} + app .listen({ port: config.apiPort, host: process.env.HOST ?? "0.0.0.0" }) .then((address) => { diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 679916e..3c0f5d8 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -725,6 +725,218 @@ body { margin: 0 0 1.1rem; } +/* "Feed the PYRE more" contribution slider */ +.contribute { + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 100%; + max-width: 28rem; + padding: 0.85rem 1rem; + border: 1px dashed rgba(255, 138, 61, 0.35); + border-radius: 0.6rem; + background: rgba(255, 87, 34, 0.04); +} +.contribute__label { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + font-size: 0.92rem; + color: var(--color-ember-bright); +} +.contribute__value { + font-variant-numeric: tabular-nums; + color: #f5ede6; +} +.contribute__slider { + width: 100%; + accent-color: var(--color-ember); + cursor: pointer; +} +.contribute__slider:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.contribute__hint { + color: var(--color-smoke); + font-size: 0.78rem; + line-height: 1.4; +} + +/* Transparent fee breakdown line (shown before signing) */ +.fee-line { + margin: 0 0 0.85rem; + padding: 0.65rem 0.85rem; + border: 1px solid rgba(255, 138, 61, 0.3); + border-radius: 0.5rem; + background: rgba(255, 87, 34, 0.06); + font-size: 0.88rem; + line-height: 1.5; + font-variant-numeric: tabular-nums; +} +.fee-line__gross { + font-weight: 700; + color: var(--color-ember-bright); +} +.fee-line__treasury { + color: #f5ede6; +} +.fee-line__net { + font-weight: 700; + color: #7be3a3; +} +.fee-line__treasury-addr { + display: inline-block; + margin-top: 0.35rem; + color: var(--color-smoke); + font-size: 0.8rem; +} + +/* Destructive burn confirmation gate */ +.burn-warn { + margin-top: 1rem; + border: 1px solid rgba(255, 60, 40, 0.5); + background: linear-gradient(180deg, rgba(255, 60, 40, 0.1), rgba(26, 20, 18, 0.7)); + border-radius: 0.85rem; + padding: 1.25rem 1.4rem 1.4rem; +} +.burn-warn__headline { + font-size: 1.15rem; + font-weight: 800; + color: #ff7a6b; + margin: 0 0 0.5rem; +} +.burn-warn__body { + color: #f5ede6; + font-size: 0.95rem; + line-height: 1.55; + margin: 0 0 0.85rem; +} +.burn-warn__confirm { + border-color: #ff5722 !important; +} + +/* "Fed the PYRE" / Essence round panel */ +.pyre-panel { + position: relative; + overflow: hidden; + border: 1px solid rgba(255, 138, 61, 0.28); + background: linear-gradient(180deg, rgba(255, 87, 34, 0.08), rgba(26, 20, 18, 0.7)); + border-radius: 1rem; + padding: 2rem 1.5rem; + text-align: center; +} +.pyre-panel__glow { + position: absolute; + top: -8rem; + left: 50%; + width: min(34rem, 90vw); + height: 22rem; + transform: translateX(-50%); + background: radial-gradient( + 50% 50% at 50% 50%, + rgba(255, 87, 34, 0.22), + transparent 70% + ); + filter: blur(24px); + pointer-events: none; + z-index: 0; +} +.pyre-panel > *:not(.pyre-panel__glow) { + position: relative; + z-index: 1; +} +.pyre-panel .section-heading { + margin-bottom: 1rem; +} +.pyre-panel__headline { + font-size: clamp(1.5rem, 5vw, 2.25rem); + font-weight: 800; + color: var(--color-ember-bright); + text-shadow: 0 0 40px rgba(255, 87, 34, 0.4); + font-variant-numeric: tabular-nums; + margin: 0; +} +.pyre-panel__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 0.6rem 0 1.5rem; + color: var(--color-smoke); + font-size: 0.95rem; +} +.pyre-panel__round { + font-weight: 600; + color: #f5ede6; +} +.pyre-panel__dot { + color: rgba(255, 138, 61, 0.5); +} +.pyre-panel__list { + list-style: none; + margin: 0 auto 1.5rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 32rem; + text-align: left; +} +.pyre-panel__row { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 0.9rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--color-coal); + border-radius: 0.5rem; +} +.pyre-panel__wallet { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + color: var(--color-smoke); +} +.pyre-panel__amount { + margin-left: auto; + font-weight: 600; + color: var(--color-ember-bright); + font-variant-numeric: tabular-nums; +} +.pyre-panel__kind { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-smoke); + padding: 0.12rem 0.5rem; + border-radius: 999px; + border: 1px solid rgba(255, 138, 61, 0.3); + background: rgba(255, 87, 34, 0.06); + white-space: nowrap; +} +.pyre-panel__note, +.pyre-panel__empty { + color: var(--color-smoke); + font-style: italic; +} +.pyre-panel__empty { + margin: 0.5rem 0 1.5rem; +} +.pyre-panel__note { + list-style: none; + text-align: center; +} +.pyre-panel__explainer { + max-width: 36rem; + margin: 0 auto; + color: var(--color-smoke); + font-size: 0.85rem; + line-height: 1.6; +} + /* Wallet adapter button — nudge toward the ember theme. */ .wallet-adapter-button-trigger { background: var(--color-coal) !important; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 628af82..1831044 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -3,6 +3,7 @@ import { useWallet } from "@solana/wallet-adapter-react"; import { Hero } from "../components/Hero"; import { Scanner } from "../components/Scanner"; +import { PyrePanel } from "../components/PyrePanel"; import { HowItWorks } from "../components/HowItWorks"; import { Features } from "../components/Features"; import { Footer } from "../components/Footer"; @@ -14,6 +15,7 @@ export default function HomePage() {
+