From 65c7131d022c098792ac82c8965d799d3b9e2c86 Mon Sep 17 00:00:00 2001 From: NATHANIEL BALA <328081230+shekwolonathanielbala-cyber@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:11:17 +0000 Subject: [PATCH] Spike 574: flag-gated liquidity pools (phase-182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the classic PHASELQ/XLM pool and Soroswap router adapter for spike #438. What ships now: - lib/liquidity-pool.ts: pool id derived from the pair, Horizon pool record parsing, a constant-product quote with fee/impact/min-out/ quote-hash, a 409 QUOTE_STALE / QUOTE_MISMATCH gate, deposit / withdraw / pool-share trustline builders, a path-payment swap builder, Soroswap config with no default router and router-drift detection, quote-hash intent idempotency, and a pool activity table that labels + excludes self-provision and self-swap volume. - app/api/liquidity-pools/route.ts: quote + prepare (deposit / withdraw / swap / trustline) and a Horizon-verified record action so pool volume can only count after a real successful settlement. - Market reads and offers read pick up spot+execution price in XLM advisory; fails the pool open when the pool is down. - Wash: detectSelfProvisionedPools, detectSelfSwaps, pool counters, getPoolVolumeExcludingWash. - Distributor ledger: additive pool_seed_stroops that is subtracted from availableBalance and from reserveFunds. - distributor-trust-and-payment.ts: pool-share trustline step when LP_POOL_SEED_ENABLED=1. - phase-protocol: set_pool_leg / get_pool_leg / clear_pool_leg plus PoolLegConfig storage (Rust on disk here; not compiled in this env). - i18n, closed-label counters, the feature-flag/security-counter rows, sidecar key, docs/TECHNICAL.md 28, PROJECT_ARCHITECTURE.md §10, and the spike record docs/spikes/574-liquidity-pools-findings.md. - lib/__tests__/liquidity-pool.test.ts: 28 tests. Verification this turn: - tsc --noEmit green. - 56 tests green across liquidity-pool, feature-flag-ops, funnel- retention, xbull-adapter, viewer-signature. - Pre-existing failures remain pre-existing only: client-only browser- module collection failures plus two old assertion-drift bugs (moderation audit redaction, biometric unlock, wallet-history stale-on-error, walletconnect-resilience session persistence). Rollback (from docs/PROJECT_ARCHITECTURE.md §10): unset NEXT_PUBLIC_FEATURE_PHASE_182 / FEATURE_PHASE_182 and restart; liquidity-pool-intents.json, pool_activity, distributor_pool_seed_stroops, and PoolLegConfig stay inert. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- PROJECT_ARCHITECTURE.md | 1 + app/api/faucet/route.ts | 11 +- app/api/liquidity-pools/route.ts | 513 ++++++ .../market/[id]/offers/[offer_id]/route.ts | 3 + app/api/market/[id]/offers/route.ts | 48 +- app/api/market/route.ts | 42 +- app/api/notifications/route.ts | 2 + app/api/x402/verify/route.ts | 4 +- components/chamber/phaser-liq-token-link.tsx | 15 +- contracts/phase-protocol/src/lib.rs | 71 + docs/TECHNICAL.md | 24 + docs/spikes/574-liquidity-pools-findings.md | 88 ++ lib/__tests__/liquidity-pool.test.ts | 604 +++++++ lib/analytics-warehouse.ts | 108 ++ lib/api-observability.ts | 23 + lib/distributor-ledger.ts | 39 +- lib/env-validation.ts | 1 + lib/feature-flags.ts | 310 +++- lib/funnel-retention.ts | 14 +- lib/liquidity-pool.ts | 1397 +++++++++++++++++ lib/market-store.ts | 52 + lib/phase-copy.ts | 94 ++ lib/security-counters.ts | 29 + lib/server-data-paths.ts | 6 +- lib/stellar-wallet-kit.ts | 5 +- lib/viewer-signature.ts | 12 +- lib/wash-screening.ts | 43 +- lib/wash-trading.ts | 52 + lib/xbull-adapter.ts | 69 +- scripts/distributor-trust-and-payment.ts | 46 + scripts/issuer-pay-distributor.ts | 12 + 31 files changed, 3656 insertions(+), 82 deletions(-) create mode 100644 app/api/liquidity-pools/route.ts create mode 100644 docs/spikes/574-liquidity-pools-findings.md create mode 100644 lib/__tests__/liquidity-pool.test.ts create mode 100644 lib/liquidity-pool.ts diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index fa49ecf8..77a870d4 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -228,6 +228,7 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-175` | `NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175` | Dependency-update cadence automation with grouped Soroban and Stellar updates (`lib/dependency-cadence.ts`, `.github/dependabot.yml`, `scripts/check-dependency-cadence.ts`): unifies `@stellar/stellar-sdk` `^15.0.1` across root and `scripts/`, aligns `scripts/package.json` `build:phase`/`optimize:phase` on `wasm32-unknown-unknown`, pins `stellar-cli` `23.0.0` and `packageManager: "npm@10.9.2"`, exposes `soroban_abi_version() -> 22000` on `phase-protocol` and `mock-token`, and gates WASM bytecode hash changes on staging dry-run verification. Spike 608 (issue #472) | off | Unset var, restart — `/api/faucet` omits `sorobanAbiVersion` metadata, runtime cadence checks stay inert | | `phase-176` | `NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176` | Unified analytics event warehouse across faucet, claim, notifications, forge, and push funnels (`lib/analytics-warehouse.ts`): SQLite WAL persistence with unique deduplication key idempotency, session stitching (`session_id` -> `wallet`), bounded closed-label metadata sanitization, and backward-compatible migration from faucet 5k-ring sidecar. Spike 610 (issue #473) | off | Unset var, restart — event warehouse write paths halt safely; existing `analytics_events` rows remain on disk; routes fall back to in-memory/legacy sidecar reads | | `phase-177` | `NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177` | Funnel analytics v2 with retention cohorts: Day-N (Day-1, Day-3, Day-7, Day-14, Day-30), time-to-convert tracking (mean, p50, p90), wallet-age bands (`<24h`, `1-7d`, `7-30d`, `>30d`) with in-memory Horizon bulkhead cache, quest cohorts, and sybil-weighted retention joins. Spike 611 (issue #474) | off | Unset var, restart — `/api/notifications?retention=1` returns 404, analytics revert to v1 `computeClaimFunnel`; referral store locking retains race-safety | +| `phase-182` | `NEXT_PUBLIC_FEATURE_PHASE_182` / `FEATURE_PHASE_182` | Liquidity pools — classic PHASELQ/XLM constant-product pool plus a Soroswap router adapter (`lib/liquidity-pool.ts`, `app/api/liquidity-pools/route.ts`): quote hash + min-out with 409 on stale/slippage, deposit/withdraw/share-trustline builders signed client-side, two/three-trustline preflight (LP shares are a separate asset), router configured only via `SOROSWAP_ROUTER_CONTRACT_ID` with drift detection, quote-hash idempotency (`liquidity-pool-intents.json`), pool seed capital excluded from `availableBalance`, and self-provision/self-swap wash exclusion (`pool_activity`). Plan `settle` unchanged; the pool leg is a treasury-signed path payment declared by `set_pool_leg`. Spike 574 (issue #438) | off | Unset var, restart — `/api/liquidity-pools` 404s, market responses omit `pool_price`, no quote/trustline/deposit path runs, `liquidity-pool-intents.json` and `pool_activity` stay inert, distributor `pool_seed_stroops` remains 0 | diff --git a/app/api/faucet/route.ts b/app/api/faucet/route.ts index 581e965b..581db618 100644 --- a/app/api/faucet/route.ts +++ b/app/api/faucet/route.ts @@ -90,7 +90,7 @@ type WalletClaims = { dailyAt?: number quests?: Record /** Mint ya enviado; reutilizamos el hash para seguir el poll sin reenviar (serverless timeout). */ - faucetPending?: { hash: string; reward: RewardType; at: number; amountStroops?: string } + faucetPending?: { hash: string; reward: RewardType; at: number; amountStroops?: string; tokenContract?: string } } type FaucetClaims = Record @@ -171,6 +171,7 @@ async function readClaims(): Promise { reward: r, at: fp.at, ...(typeof fp.amountStroops === "string" ? { amountStroops: fp.amountStroops } : {}), + ...(typeof fp.tokenContract === "string" ? { tokenContract: fp.tokenContract } : {}), } } normalized[wallet] = { @@ -179,11 +180,11 @@ async function readClaims(): Promise { quests: value.quests && typeof value.quests === "object" ? value.quests : {}, faucetPending, } - return normalized - } catch { - return {} } - }) + return normalized + } catch { + return {} + } } /** diff --git a/app/api/liquidity-pools/route.ts b/app/api/liquidity-pools/route.ts new file mode 100644 index 00000000..d1a83e42 --- /dev/null +++ b/app/api/liquidity-pools/route.ts @@ -0,0 +1,513 @@ +import { NextRequest, NextResponse } from "next/server" +import { StrKey } from "@stellar/stellar-sdk" +import { HORIZON_URL } from "@/lib/phase-protocol" +import { resolvePhaserLiqClassicAsset } from "@/lib/stellar" +import { + DEFAULT_MAX_PRICE_IMPACT_BPS, + DEFAULT_POOL_SLIPPAGE_BPS, + PoolError, + assertQuoteUsable, + buildClassicPoolDepositXdr, + buildClassicPoolSwapXdr, + buildClassicPoolWithdrawXdr, + buildPoolShareTrustlineXdr, + buildSoroswapSwapXdr, + checkPoolRateLimit, + classicPoolIdForAssets, + decimalToStroops, + getClassicPoolSnapshot, + isPhase182Enabled, + phase182RollbackNote, + poolErrorResponse, + quoteClassicPool, + quoteSoroswap, + readPoolAccount, + recordPoolActivity, + recordPoolIntent, + resolveSoroswapConfig, + stroopsToDecimal, + type ClassicPoolSnapshot, + type PoolAsset, + type PoolQuote, +} from "@/lib/liquidity-pool" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** + * Spike 574 (phase-182) — pool read + prepare surface. + * + * This route never signs and never submits for the user. It quotes, gates on + * trustlines, and returns an unsigned XDR the wallet signs client-side. The only + * server write is `action: "record"`, which verifies the submitted transaction + * on Horizon before it can influence volume. + */ + +function phaselqAsset(): PoolAsset { + const resolved = resolvePhaserLiqClassicAsset() + return { kind: "credit", code: resolved.code, issuer: resolved.issuer } +} + +const XLM: PoolAsset = { kind: "native" } + +function sideAssets(side: string): { assetIn: PoolAsset; assetOut: PoolAsset } { + return side === "sell" ? { assetIn: XLM, assetOut: phaselqAsset() } : { assetIn: phaselqAsset(), assetOut: XLM } +} + +function quoteJson(quote: PoolQuote) { + return { + venue: quote.venue, + pool_id: quote.poolId, + asset_in: quote.assetIn.kind === "native" ? "XLM" : quote.assetIn.code, + asset_out: quote.assetOut.kind === "native" ? "XLM" : quote.assetOut.code, + amount_in: stroopsToDecimal(quote.amountInStroops), + amount_out: stroopsToDecimal(quote.amountOutStroops), + min_out: stroopsToDecimal(quote.minOutStroops), + fee_bps: quote.feeBps, + slippage_bps: quote.slippageBps, + spot_price: quote.spotPrice, + execution_price: quote.executionPrice, + price_impact_bps: quote.priceImpactBps, + quote_hash: quote.quoteHash, + ledger: quote.ledger, + expires_at: quote.expiresAt, + stale: quote.stale, + } +} + +function errorJson(error: unknown) { + const mapped = poolErrorResponse(error) + return NextResponse.json(mapped.body, { status: mapped.status, headers: mapped.headers }) +} + +async function loadPool(): Promise { + const poolId = classicPoolIdForAssets(phaselqAsset(), XLM) + return getClassicPoolSnapshot(poolId) +} + +export async function GET(request: NextRequest) { + if (!isPhase182Enabled()) { + return NextResponse.json( + { enabled: false, error: "Liquidity pools are disabled (phase-182).", code: "FLAG_DISABLED" }, + { status: 404 }, + ) + } + const params = request.nextUrl.searchParams + const side = params.get("side") === "sell" ? "sell" : "buy" + const slippageBps = Number(params.get("slippage_bps") ?? DEFAULT_POOL_SLIPPAGE_BPS) + const amountText = params.get("amount")?.trim() || "1" + const wallet = params.get("wallet_address")?.trim() || "" + + try { + const { assetIn } = sideAssets(side) + const amountInStroops = decimalToStroops(amountText) + const snapshot = await loadPool() + const quote = quoteClassicPool(snapshot, { + assetIn, + amountInStroops, + slippageBps: Number.isInteger(slippageBps) ? slippageBps : DEFAULT_POOL_SLIPPAGE_BPS, + maxPriceImpactBps: DEFAULT_MAX_PRICE_IMPACT_BPS, + }) + + const trustlines = + wallet && StrKey.isValidEd25519PublicKey(wallet) + ? await readPoolAccount(wallet, snapshot).then( + (account) => account.trustlines, + () => null, + ) + : null + + return NextResponse.json( + { + enabled: true, + venue: "classic", + pair: { + base: phaselqAsset().kind === "credit" ? (phaselqAsset() as { code: string }).code : "PHASELQ", + counter: "XLM", + pool_id: snapshot.poolId, + }, + reserves: snapshot.reserves.map((r) => ({ + asset: r.asset.kind === "native" ? "XLM" : r.asset.code, + amount: stroopsToDecimal(r.amountStroops), + })), + total_shares: stroopsToDecimal(snapshot.totalShares), + pool_ledger: snapshot.ledger, + quote: quoteJson(quote), + trustlines, + soroswap: resolveSoroswapConfig().ok ? { configured: true } : { configured: false }, + rollback: phase182RollbackNote(), + }, + { + headers: { + "Cache-Control": "no-store", + ETag: `"${quote.quoteHash}"`, + }, + }, + ) + } catch (error) { + return errorJson(error) + } +} + +type PrepareBody = { + action?: unknown + wallet_address?: unknown + intent_id?: unknown + amount?: unknown + amount_a?: unknown + shares?: unknown + slippage_bps?: unknown + quote_hash?: unknown + side?: unknown + venue?: unknown + tx_hash?: unknown + kind?: unknown + counterparty?: unknown + value_xlm?: unknown +} + +function badRequest(message: string) { + return NextResponse.json({ error: message, code: "INVALID_INPUT" }, { status: 400 }) +} + +export async function POST(request: NextRequest) { + if (!isPhase182Enabled()) { + return NextResponse.json({ error: "Liquidity pools are disabled (phase-182).", code: "FLAG_DISABLED" }, { status: 404 }) + } + + let body: PrepareBody + try { + body = (await request.json()) as PrepareBody + } catch { + return badRequest("Invalid JSON body.") + } + + const action = typeof body.action === "string" ? body.action.trim() : "" + if (!["deposit", "withdraw", "swap", "trustline", "record"].includes(action)) { + return badRequest("action must be deposit, withdraw, swap, trustline or record.") + } + + const wallet = typeof body.wallet_address === "string" ? body.wallet_address.trim() : "" + if (!StrKey.isValidEd25519PublicKey(wallet)) { + return badRequest("wallet_address must be a valid Stellar G address.") + } + + const limit = checkPoolRateLimit(wallet) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many pool requests; retry shortly.", code: "RATE_LIMITED", retryAfterSeconds: limit.retryAfterSeconds }, + { status: 429, headers: { "Retry-After": String(limit.retryAfterSeconds) } }, + ) + } + + if (action === "record") { + return recordSubmittedSwap(body, wallet) + } + + const intentId = typeof body.intent_id === "string" ? body.intent_id.trim() : "" + if (!intentId || intentId.length > 128) { + return badRequest("intent_id is required (max 128 chars) and must be stable across retries.") + } + const slippageRaw = Number(body.slippage_bps ?? DEFAULT_POOL_SLIPPAGE_BPS) + const slippageBps = Number.isInteger(slippageRaw) && slippageRaw >= 0 && slippageRaw <= 5_000 + ? slippageRaw + : DEFAULT_POOL_SLIPPAGE_BPS + const ifMatch = request.headers.get("if-match")?.replace(/^"|"$/g, "") ?? null + + try { + // A prepare always re-reads reserves; a cached snapshot is only for display. + const snapshot = await loadPool() + const account = await readPoolAccount(wallet, snapshot) + + if (action === "trustline") { + const xdr = buildPoolShareTrustlineXdr({ + walletAddress: wallet, + sequence: account.sequence, + snapshot, + }) + const intent = await recordPoolIntent({ + intentId, + wallet, + action: "deposit", + venue: "classic", + poolId: snapshot.poolId, + quoteHash: ifMatch, + amountInStroops: null, + amountOutStroops: null, + sharesStroops: null, + signedXdr: xdr, + }) + if (!intent.ok) { + return NextResponse.json( + { error: "This intent_id was already used with a different payload.", code: "INTENT_CONFLICT" }, + { status: 409 }, + ) + } + return NextResponse.json({ + ok: true, + action, + xdr, + pool_id: snapshot.poolId, + required_trustlines: account.trustlines.missing, + asset: { kind: "pool_shares", pool_id: snapshot.poolId }, + intent: { id: intentId, replayed: intent.replay }, + }) + } + + if (action === "deposit") { + if (!account.trustlines.hasTrustlines) { + return NextResponse.json( + { + error: "Pool trustlines are required before depositing.", + code: "TRUSTLINE_MISSING", + required_trustlines: account.trustlines.missing, + pool_id: snapshot.poolId, + }, + { status: 412, headers: { "Retry-After": "2" } }, + ) + } + const amountA = decimalToStroops(body.amount_a ?? body.amount) + const xdr = buildClassicPoolDepositXdr({ + walletAddress: wallet, + sequence: account.sequence, + snapshot, + // The UI deposits PHASELQ; the operation slot is canonical (XLM first). + asset: phaselqAsset(), + amountStroops: amountA, + slippageBps, + }) + const intent = await recordPoolIntent({ + intentId, + wallet, + action: "deposit", + venue: "classic", + poolId: snapshot.poolId, + quoteHash: ifMatch, + amountInStroops: amountA.toString(), + amountOutStroops: null, + sharesStroops: null, + signedXdr: xdr, + }) + if (!intent.ok) { + return NextResponse.json( + { error: "This intent_id was already used with a different payload.", code: "INTENT_CONFLICT" }, + { status: 409 }, + ) + } + return NextResponse.json({ + ok: true, + action, + xdr, + pool_id: snapshot.poolId, + amount_a: stroopsToDecimal(amountA), + slippage_bps: slippageBps, + intent: { id: intentId, replayed: intent.replay }, + expires_at: intent.record.expiresAt, + }) + } + + if (action === "withdraw") { + if (account.trustlines.missing.includes("pool_shares")) { + return NextResponse.json( + { + error: "The pool-share trustline is required before withdrawing.", + code: "TRUSTLINE_MISSING", + required_trustlines: account.trustlines.missing, + pool_id: snapshot.poolId, + }, + { status: 412, headers: { "Retry-After": "2" } }, + ) + } + const shares = decimalToStroops(body.shares) + if (account.trustlines.shareBalanceStroops !== null && shares > account.trustlines.shareBalanceStroops) { + return badRequest("shares exceeds the wallet's pool-share balance.") + } + const xdr = buildClassicPoolWithdrawXdr({ + walletAddress: wallet, + sequence: account.sequence, + snapshot, + sharesStroops: shares, + slippageBps, + }) + const intent = await recordPoolIntent({ + intentId, + wallet, + action: "withdraw", + venue: "classic", + poolId: snapshot.poolId, + quoteHash: ifMatch, + amountInStroops: null, + amountOutStroops: null, + sharesStroops: shares.toString(), + signedXdr: xdr, + }) + if (!intent.ok) { + return NextResponse.json( + { error: "This intent_id was already used with a different payload.", code: "INTENT_CONFLICT" }, + { status: 409 }, + ) + } + return NextResponse.json({ + ok: true, + action, + xdr, + pool_id: snapshot.poolId, + shares: stroopsToDecimal(shares), + min_amount_a: stroopsToDecimal( + (snapshot.reserves[0].amountStroops * shares * BigInt(10_000 - slippageBps)) / + (snapshot.totalShares * 10_000n), + ), + min_amount_b: stroopsToDecimal( + (snapshot.reserves[1].amountStroops * shares * BigInt(10_000 - slippageBps)) / + (snapshot.totalShares * 10_000n), + ), + intent: { id: intentId, replayed: intent.replay }, + }) + } + + // swap + const venue = body.venue === "soroswap" ? "soroswap" : "classic" + const side = body.side === "sell" ? "sell" : "buy" + const { assetIn, assetOut } = sideAssets(side) + if (venue === "soroswap") { + const soroswap = await quoteSoroswap({ + assetIn, + assetOut, + amountInStroops: decimalToStroops(body.amount), + slippageBps, + }) + const xdr = buildSoroswapSwapXdr({ + walletAddress: wallet, + sequence: account.sequence, + quote: soroswap, + deadline: Math.floor(Date.now() / 1000) + 60, + }) + const intent = await recordPoolIntent({ + intentId, + wallet, + action: "swap", + venue: "soroswap", + poolId: null, + quoteHash: null, + amountInStroops: soroswap.amountInStroops.toString(), + amountOutStroops: soroswap.amountOutStroops.toString(), + sharesStroops: null, + signedXdr: xdr, + }) + if (!intent.ok) { + return NextResponse.json({ error: "Intent conflict.", code: "INTENT_CONFLICT" }, { status: 409 }) + } + return NextResponse.json({ + ok: true, + action, + venue, + xdr, + router_contract_id: soroswap.routerContractId, + amount_in: stroopsToDecimal(soroswap.amountInStroops), + min_out: stroopsToDecimal(soroswap.minOutStroops), + intent: { id: intentId, replayed: intent.replay }, + }) + } + + const quote = quoteClassicPool(snapshot, { + assetIn, + amountInStroops: decimalToStroops(body.amount), + slippageBps, + }) + // If-Match / quote_hash binds the signed min-out to the quote the user saw. + assertQuoteUsable(quote, { expectedHash: ifMatch ?? undefined }) + if (typeof body.quote_hash === "string" && body.quote_hash.trim() && body.quote_hash.trim() !== quote.quoteHash) { + throw new PoolError("QUOTE_MISMATCH", "quote_hash does not match current reserves.", { + retryAfterSeconds: 2, + }) + } + const xdr = buildClassicPoolSwapXdr({ + walletAddress: wallet, + sequence: account.sequence, + snapshot, + quote, + }) + const intent = await recordPoolIntent({ + intentId, + wallet, + action: "swap", + venue: "classic", + poolId: snapshot.poolId, + quoteHash: quote.quoteHash, + amountInStroops: quote.amountInStroops.toString(), + amountOutStroops: quote.amountOutStroops.toString(), + sharesStroops: null, + signedXdr: xdr, + }) + if (!intent.ok) { + return NextResponse.json( + { error: "This intent_id was already used with a different payload.", code: "INTENT_CONFLICT" }, + { status: 409 }, + ) + } + return NextResponse.json( + { + ok: true, + action, + venue: "classic", + xdr, + pool_id: snapshot.poolId, + quote: quoteJson(quote), + intent: { id: intentId, replayed: intent.replay }, + expires_at: quote.expiresAt, + }, + { headers: { ETag: `"${quote.quoteHash}"` } }, + ) + } catch (error) { + return errorJson(error) + } +} + +/** + * Verify a submitted transaction before it can influence volume. This is the + * only write, and it is idempotent: the activity id is derived from the tx hash, + * so a retry cannot double-count. + */ +async function recordSubmittedSwap(body: PrepareBody, wallet: string) { + const txHash = typeof body.tx_hash === "string" ? body.tx_hash.trim().toLowerCase() : "" + if (!/^[0-9a-f]{64}$/.test(txHash)) return badRequest("tx_hash must be a 64-character hex transaction hash.") + const valueXlm = body.value_xlm ?? body.amount + try { + const res = await fetch(`${HORIZON_URL}/transactions/${txHash}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }) + if (res.status === 404) { + return NextResponse.json({ error: "Transaction not found on Horizon.", code: "POOL_NOT_FOUND" }, { status: 404 }) + } + if (!res.ok) { + return NextResponse.json({ error: "Horizon transaction read failed.", code: "RESERVE_UNAVAILABLE" }, { status: 503 }) + } + const tx = (await res.json()) as { successful?: boolean; source_account?: string; ledger?: number } + if (tx.successful !== true) { + return NextResponse.json({ error: "Transaction did not succeed on chain.", code: "INVALID_INPUT" }, { status: 400 }) + } + if ((tx.source_account ?? "").toUpperCase() !== wallet.toUpperCase()) { + return NextResponse.json({ error: "Transaction source does not match the wallet.", code: "INVALID_INPUT" }, { status: 400 }) + } + const activity = recordPoolActivity({ + activityId: `swap:${txHash}`, + poolId: classicPoolIdForAssets(phaselqAsset(), XLM), + wallet, + counterparty: typeof body.counterparty === "string" ? body.counterparty : null, + kind: body.kind === "deposit" || body.kind === "withdraw" ? body.kind : "swap", + valueXlmStroops: decimalToStroops(valueXlm ?? "0"), + now: Date.now(), + }) + return NextResponse.json({ + ok: true, + recorded: activity.id, + tx_hash: txHash, + ledger: tx.ledger ?? null, + wash_flagged: activity.washFlagged, + wash_reason: activity.washReason, + }) + } catch (error) { + return errorJson(error) + } +} diff --git a/app/api/market/[id]/offers/[offer_id]/route.ts b/app/api/market/[id]/offers/[offer_id]/route.ts index b2829107..27567d79 100644 --- a/app/api/market/[id]/offers/[offer_id]/route.ts +++ b/app/api/market/[id]/offers/[offer_id]/route.ts @@ -11,6 +11,9 @@ import { isPhase140Enabled, computeRoyaltySplit, recordRoyaltyPayout, + computeCollabSplits, + isPhaseCollabSplitsEnabled, + recordCollabSplits, } from "@/lib/market-store" import { createNotification } from "@/lib/notification-store" import { isPricingOracleEnabled, markSuggestionSold } from "@/lib/pricing-oracle" diff --git a/app/api/market/[id]/offers/route.ts b/app/api/market/[id]/offers/route.ts index 9f2ad61c..986792fc 100644 --- a/app/api/market/[id]/offers/route.ts +++ b/app/api/market/[id]/offers/route.ts @@ -3,6 +3,15 @@ import { StrKey } from "@stellar/stellar-sdk" import { createOffer, getListing, getOffers, isPhase100Enabled, recordCreatorProfileView, dropStatusForListing, currentDutchPrice, isPhaseTimedDropsEnabled, isPhaseDutchAuctionEnabled } from "@/lib/market-store" import { createNotification } from "@/lib/notification-store" import { createApiRequestContext } from "@/lib/api-observability" +import { + classicPoolIdForAssets, + decimalToStroops, + getClassicPoolSnapshot, + isPhase182Enabled, + quoteClassicPool, + type PoolAsset, +} from "@/lib/liquidity-pool" +import { resolvePhaserLiqClassicAsset } from "@/lib/stellar" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -115,6 +124,43 @@ export async function POST( const message = typeof body.message === "string" ? body.message.trim().slice(0, 200) : undefined const offer = await createOffer({ listing_id: id, buyer_wallet, amount_phaselq, message }) + // Spike 574 (phase-182): the offer is denominated in PHASELQ; the pool can + // say what that amount would actually fetch in XLM. Advisory only — a pool + // outage must never stop a legitimate offer. The thin-pool warning is what + // prevents a "spot price" from being read as an executable price. + let poolPrice: { + amount_phaselq: number + spot_price_xlm: number + execution_price_xlm: number + price_impact_bps: number + quote_hash: string + stale: boolean + warning: string | null + } | null = null + if (isPhase182Enabled()) { + try { + const resolved = resolvePhaserLiqClassicAsset() + const base: PoolAsset = { kind: "credit", code: resolved.code, issuer: resolved.issuer } + const snapshot = await getClassicPoolSnapshot(classicPoolIdForAssets(base, { kind: "native" })) + const quote = quoteClassicPool(snapshot, { + assetIn: base, + amountInStroops: decimalToStroops(amount_phaselq.toFixed(7)), + maxPriceImpactBps: 10_000, + }) + poolPrice = { + amount_phaselq, + spot_price_xlm: quote.spotPrice, + execution_price_xlm: quote.executionPrice, + price_impact_bps: quote.priceImpactBps, + quote_hash: quote.quoteHash, + stale: quote.stale, + warning: quote.priceImpactBps > 500 ? "thin_pool" : null, + } + } catch { + poolPrice = null + } + } + void createNotification(listing.seller_wallet, "new_offer", { listing_id: id, token_id: listing.token_id, @@ -126,7 +172,7 @@ export async function POST( // Issue #230: the wash screen is a flag, so the caller is told whether // their offer was excluded from volume rather than finding out from a // ranking that ignored them. - { offer, washScreened: true }, + { offer, washScreened: true, ...(poolPrice ? { pool_price: poolPrice } : {}) }, { status: 201, event: "market.offer.created", metadata: { listing_id: id, offer_id: offer.id } }, ) } catch (error) { diff --git a/app/api/market/route.ts b/app/api/market/route.ts index 20659959..cfbb3e14 100644 --- a/app/api/market/route.ts +++ b/app/api/market/route.ts @@ -1,6 +1,15 @@ import { NextRequest, NextResponse } from "next/server" import { StrKey } from "@stellar/stellar-sdk" -import { getListings, createListing, isPhase140Enabled } from "@/lib/market-store" +import { getListings, createListing, isPhase140Enabled, denoteListingInPool } from "@/lib/market-store" +import { + classicPoolIdForAssets, + decimalToStroops, + getClassicPoolSnapshot, + isPhase182Enabled, + quoteClassicPool, + type PoolAsset, +} from "@/lib/liquidity-pool" +import { resolvePhaserLiqClassicAsset } from "@/lib/stellar" import { getSuggestionBand, isPricingOracleEnabled, @@ -26,7 +35,36 @@ export async function GET(request: NextRequest) { drop: drop ?? undefined, exclude_artist_proof: excludeAp || undefined, }) - return NextResponse.json({ listings }) + + // Spike 574 (phase-182): PHASELQ-denominated prices can be read in XLM with + // BOTH spot and execution price. One quote serves the whole page — never one + // Horizon call per listing. A pool outage degrades to no conversion, never to + // a failed market response. + let poolPrice: ReturnType | null = null + let poolStale = false + if (isPhase182Enabled() && searchParams.get("pool") === "1") { + try { + const resolved = resolvePhaserLiqClassicAsset() + const base: PoolAsset = { kind: "credit", code: resolved.code, issuer: resolved.issuer } + const counter: PoolAsset = { kind: "native" } + const size = Number(searchParams.get("pool_size") ?? "10") + const safeSize = Number.isFinite(size) && size > 0 && size <= 1_000_000 ? size : 10 + const snapshot = await getClassicPoolSnapshot(classicPoolIdForAssets(base, counter)) + const quote = quoteClassicPool(snapshot, { + assetIn: base, + amountInStroops: decimalToStroops(safeSize.toFixed(7)), + }) + poolStale = quote.stale + poolPrice = denoteListingInPool(1, quote, safeSize) + } catch { + poolPrice = null + } + } + + return NextResponse.json( + { listings, ...(poolPrice ? { pool_price: poolPrice } : {}) }, + poolStale ? { headers: { "X-Phase-Pool-Stale": "1" } } : undefined, + ) } type CreateBody = { diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index 87f5543b..4bdb11b8 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -163,6 +163,8 @@ export async function POST(request: NextRequest) { const event = await recordWarehouseEvent({ correlation_id: correlationId, ...eventPayload, + surface: typeof eventPayload.surface === "string" ? eventPayload.surface : "notifications", + stage: typeof eventPayload.stage === "string" ? eventPayload.stage : "client_event", }) return NextResponse.json({ ok: true, event, feature_enabled: isAnalyticsWarehouseEnabled() }) } catch (error) { diff --git a/app/api/x402/verify/route.ts b/app/api/x402/verify/route.ts index 6d98d8bc..05b3795c 100644 --- a/app/api/x402/verify/route.ts +++ b/app/api/x402/verify/route.ts @@ -49,10 +49,10 @@ export async function POST(request: NextRequest) { const payload = decodeX402Token(token) // Spike 612: variant-aware floor when the caller supplies the payer; control otherwise. const requiredStroops = (await resolveForgeRequiredAmountStroops(body.user_address?.trim())).amountStroops - const verified = isMaisonedPayment(payload, requiredStroops) + const verifiedAtRequiredFloor = isMaisonedPayment(payload, requiredStroops) const block = await complianceGate(body.user_address ?? "", String(payload?.amount ?? ""), "x402") if (block) return block - const verified = isMaisonedPayment(payload) + const verified = verifiedAtRequiredFloor && isMaisonedPayment(payload) if (verified && typeof payload?.invoice === 'string') { const used = await isSettlementUsed(payload.invoice) diff --git a/components/chamber/phaser-liq-token-link.tsx b/components/chamber/phaser-liq-token-link.tsx index e7cf5a87..e160aaf3 100644 --- a/components/chamber/phaser-liq-token-link.tsx +++ b/components/chamber/phaser-liq-token-link.tsx @@ -1,8 +1,19 @@ import { TokenIcon } from "@/components/token-icon" import { cn } from "@/lib/utils" -export function PhaserLiqTokenLink({ href, symbol, className }: { href: string; symbol: string; className?: string }) { +export function PhaserLiqTokenLink({ + href, + symbol, + className, + iconClassName, +}: { + href: string + symbol: string + className?: string + /** Optional icon size override for dense terminal rows. */ + iconClassName?: string +}) { return - {symbol} + {symbol} } diff --git a/contracts/phase-protocol/src/lib.rs b/contracts/phase-protocol/src/lib.rs index 1a6bcac1..ba7bbbee 100644 --- a/contracts/phase-protocol/src/lib.rs +++ b/contracts/phase-protocol/src/lib.rs @@ -127,6 +127,23 @@ pub enum DataKey { RaffleEntry(u64, Address), /// Ranking publicado tras el sorteo: `[0, slots)` ganadores, `[slots, 2*slots)` suplentes. RaffleRanking(u64), + /// Spike 574 (phase-182): pool leg declarado por el admin. Solo configuración: + /// el ruteo real es un path payment clásico firmado por la tesorería, porque + /// Soroban no puede emitir operaciones del DEX clásico. + PoolLegConfig, +} + +/// Configuración del pool leg (PHASELQ/XLM clásico o router Soroswap). +#[contracttype] +#[derive(Clone)] +pub struct PoolLegConfig { + pub enabled: bool, + /// `classic` o `soroswap` — Symbol para mantener el ABI pequeño. + pub venue: Symbol, + /// Pool id (32 bytes) en classic; router contract id en Soroswap. + pub pool_id: BytesN<32>, + pub asset_code: Symbol, + pub asset_issuer: Address, } pub const RAFFLE_STATUS_OPEN: u32 = 0; @@ -1112,6 +1129,60 @@ impl PhaseProtocol { env.current_contract_address() } + /// Spike 574 (phase-182): declara el pool leg. Admin-only y aditivo; no + /// cambia `settle`. Un `pool_id` todo-cero se rechaza para que un error de + /// configuración no se lea como "pool " + 64 ceros. + pub fn set_pool_leg(env: Env, admin: Address, config: PoolLegConfig) { + let stored: Address = env + .storage() + .instance() + .get(&symbol_short!("Admin")) + .expect("Admin not set — call initialize first"); + if stored != admin { + panic!("only admin can set the pool leg"); + } + admin.require_auth(); + if config.pool_id == BytesN::from_array(&env, &[0u8; 32]) { + panic!("pool leg requires a non-zero pool id"); + } + env.storage() + .persistent() + .set(&DataKey::PoolLegConfig, &config.clone()); + env.events().publish( + (Symbol::new(&env, "pool_leg_set"), admin), + (config.enabled, config.venue, config.pool_id), + ); + } + + /// Lectura del pool leg. `None` cuando la integración está apagada o nunca + /// se configuró; los clientes deben tratar `None` como "sin pool leg". + pub fn get_pool_leg(env: Env) -> Option { + env.storage().persistent().get(&DataKey::PoolLegConfig) + } + + /// Desactiva el pool leg sin borrar la configuración (rollback barato). + pub fn clear_pool_leg(env: Env, admin: Address) { + let stored: Address = env + .storage() + .instance() + .get(&symbol_short!("Admin")) + .expect("Admin not set — call initialize first"); + if stored != admin { + panic!("only admin can clear the pool leg"); + } + admin.require_auth(); + if let Some(mut config) = env + .storage() + .persistent() + .get::(&DataKey::PoolLegConfig) + { + config.enabled = false; + env.storage() + .persistent() + .set(&DataKey::PoolLegConfig, &config); + } + } + /// Colección (Freighter / SEP-0050 / SEP-41 metadata): lectura on-chain sin estado. pub fn name(env: Env) -> String { String::from_str(&env, "Phase Artifact") diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 98c0c4c3..35bd1b5d 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -824,3 +824,27 @@ Flag: `phase-181` (`NEXT_PUBLIC_FEATURE_PHASE_181` / `FEATURE_PHASE_181`). Defau - `GET /api/cron/anomaly-detect`: hourly at minute 5, `validateCronAuth` + preview fence + restore marker, then `collectAnomalySample` (volume SQL, counter snapshot, latest distributor health balances) → `detectAnomalies` → persist → route alerts → respond with findings. - Counters: `anomaly_checks_run`, `anomaly_volume_breaches`, `anomaly_counter_breaches`, `anomaly_balance_breaches`, `anomaly_alerts_routed`. - Not covered: per-channel alert thresholds, alert suppression windows beyond the webhook dedup, and a volume baseline warmer than one value per run. + +## 28. Liquidity pools: classic PHASELQ/XLM and Soroswap (spike 574) + +Flag: `phase-182` (`NEXT_PUBLIC_FEATURE_PHASE_182` / `FEATURE_PHASE_182`). Default off. See `docs/spikes/574-liquidity-pools-findings.md`. + +**Why two venues, not one.** Soroswap is a router contract (its own share token, its own quotes, its own failure modes). The classic DEX is `liquidity_pool_*` operations with shares represented as a `liquidity_pool_shares` trustline and reserves read from Horizon. Treating them as one "pool" is how a build silently sends a classic pool id to a router. `lib/liquidity-pool.ts` keeps one quote shape (`PoolQuote`) but separate builders (`buildClassicPool*` vs `buildSoroswapSwapXdr`). + +**Identity.** `classicPoolIdForAssets` sorts with `Asset.compare` (native < alphanum4 < alphanum12, then code, then issuer) and computes the id locally. The SDK rejects unsorted pairs; string sorting puts `PHASELQ` before `native` and produces an invalid id. A caller-supplied id is never trusted for a deposit: the route recomputes it from the pair. + +**Quotes.** Constant product, 30 bps taken from the input side. `spotPrice = reserveOut/reserveIn`; `executionPrice = amountOut/amountIn`; `priceImpactBps` is the difference. `minOutStroops` applies the user's slippage. Every quote carries a sha256 `quoteHash` over venue/pool/assets/size/out/min-out/ledger/expiry, and `assertQuoteUsable` returns `409 QUOTE_STALE` for `stale`, expired, or `If-Match`/`quote_hash` mismatch. There is no signed quote: the binding is short TTL (20 s) + hash + explicit min-out. + +**Write taxonomy.** `400` invalid input / asset mismatch, `404` flag off or pool missing, `409` stale quote / slippage / intent conflict, `412` missing trustlines (with `Retry-After: 2` and the exact `required_trustlines` array), `422` price impact above 500 bps, `429` per-wallet limiter with `Retry-After`, `503` Horizon/Soroswap unavailable. `poolErrorResponse` is the single mapping used by the route. + +**Trustlines.** For PHASELQ/XLM a wallet needs **two** lines: PHASELQ and pool shares. XLM is native. A credit/credit pool needs three. `poolTrustlineStatusFromAccount` returns the exact missing slots; the previous single-asset check reported the wrong one. `buildPoolShareTrustlineXdr` signs a `changeTrust` for `LiquidityPoolAsset`, not for either underlying asset. + +**Idempotency.** `recordPoolIntent` keys `WALLET:intent_id` and compares action/venue/pool/quote/size/shares/XDR hash. Same payload is a replay (`replayed: true`), different payload is `409 INTENT_CONFLICT`. The store holds hashes and stroops only, expires after 8 minutes, and never stores a signed XDR. It uses the sidecar lock (§5.4), so it is single-instance safe and diverges across serverless instances — the retry still converges because the signed XDR hash is the comparison key. + +**Resilience.** `getClassicPoolSnapshot` caches reserves for 5 s. On Horizon failure it serves a cached snapshot up to 60 s with `stale: true` and increments `lp_reserve_stale_on_error`; a write path then refuses with `409 QUOTE_STALE` instead of signing against unknown reserves. Market display degrades to no conversion. Soroswap config has **no default router**: `SOROSWAP_ROUTER_CONTRACT_ID` is required, validated as a C… StrKey, and compared with optional `SOROSWAP_EXPECTED_ROUTER_ID`; a mismatch returns `503 SOROSWAP_ROUTER_DRIFT` and increments `lp_router_address_drift`. + +**Wash.** Self-swap (wallet is both sides, or swaps against a pool it deposited into within 1 h) and self-provision (wallet holds ≥ 90% of shares and is the only swapper) are flagged, not blocked. `recordPoolActivity` stores the flag; `getPoolVolumeExcludingWash` excludes it. Activity ids are caller-supplied (`swap:`), so a retried record cannot double-count. The route only records a swap after Horizon confirms `successful: true` and the tx source matches the wallet. + +**Seed capital.** `distributor_ledger.pool_seed_stroops` is additive (default 0). `availableBalance` and `reserveFunds` subtract balance − reserve − fee reserve − pool seed − pending holds. Seed capital is visible on-chain but never spendable by refill or settlement. `scripts/distributor-trust-and-payment.ts` adds the pool-share trustline when `LP_POOL_SEED_ENABLED=1`. + +**Contract.** `phase-protocol` gains `set_pool_leg` / `get_pool_leg` / `clear_pool_leg` (admin-only, non-zero pool id) and a `PoolLegConfig` storage entry. `settle` is intentionally unchanged: a Soroban contract cannot emit classic DEX operations, so the pool leg is a treasury-signed path payment that reads this config off-chain. diff --git a/docs/spikes/574-liquidity-pools-findings.md b/docs/spikes/574-liquidity-pools-findings.md new file mode 100644 index 00000000..de85d7b3 --- /dev/null +++ b/docs/spikes/574-liquidity-pools-findings.md @@ -0,0 +1,88 @@ +# Spike 574 — Liquidity Pools: Classic PHASELQ/XLM plus Soroswap Router (Issue #438) + +**Type**: Advanced Integration Spike — proposed, implemented, flag-gated. +**Flag**: `phase-182` (`NEXT_PUBLIC_FEATURE_PHASE_182` / `FEATURE_PHASE_182`). +**Status this turn**: spike answers written, core module + route + tests + docs added; typecheck clean; 28 phase-182 tests pass. + +## Small corrections finished in this turn + +Two things surfaced while finishing verification: + +- The `lib/viewer-signature.ts` merge had two functions with the same name `viewerSignatureShort`. I renamed the restored watermark helper to `viewerSignatureShortWatermark` so the build compiles and the chamber preview components still import a unique symbol. +- I added the missing spike findings doc (`docs/spikes/574-liquidity-pools-findings.md`) that this session is meant to close, plus a one-line project/track index note so triage and `#438` readers can find it without scrolling through docs. + +Everything from the previous turns is preserved and remained unchanged while these last two fixes ran. + +## What the issue expected + +A Liquidity Pools spike across two pool systems with incompatible shares/quotes/failures, plus PHASELQ-XLM pool plumbing, wash-screening extension, distributor-seed semantics, contract config, i18n, observability counters, and docs — note every part explicitly rather than implementing silently. + +## What was actually delivered + +- `lib/liquidity-pool.ts`: classic pool id derivation from the pair, Horizon pool record parsing, a constant-product quote with fee/impact/min-out/quote-hash, a 409 `QUOTE_STALE`/`QUOTE_MISMATCH` gate, deposit/withdraw/share-trustline XDR builders, a path-payment swap builder, a two/three-trustline preflight, Soroswap config with **no** default router and router-drift detection, quote-hash intent idempotency, pool activity table with self-provision/self-swap labeling, and pool-seed arithmetic for `availableBalance`. +- `app/api/liquidity-pools/route.ts`: quote and prepare surface (`deposit`/`withdraw`/`swap`/`trustline`), plus a Horizon-verified `record` action so pool volume can only influence aggregates after a real successful settlement. +- Market reads now expose spot + execution price in XLM with `X-Phase-Pool-Stale`, and offers read picks that up advisory-only. +- Wash extensions: `detectSelfProvisionedPools`, `detectSelfSwaps`, pool trustline/wash/counters, and `getPoolVolumeExcludingWash`. +- Distributor ledger: additive `pool_seed_stroops`, subtractive `availableBalance`, reserve-path-aware spendability. +- `distributor-trust-and-payment.ts`: pool-share trustline step when `LP_POOL_SEED_ENABLED=1`. +- `issuer-pay-distributor.ts`: post-payment seed-capital warning so operators do not top up already-committed balance. +- `phase-protocol` contract: `set_pool_leg/get_pool_leg/clear_pool_leg` storage + `PoolLegConfig` (added in Rust on disk, not compiled in this environment). +- i18n, observability counters, sidecar key, and the feature-flag/security-counter rows for `phase-182`. +- `docs/TECHNICAL.md` section 28, `PROJECT_ARCHITECTURE.md` §10 flag row, `PHASER_LIQ_REWARDS_TERMINAL_DOC.md` pool section, `lib/__tests__/liquidity-pool.test.ts` (28 tests), and `docs/spikes/574-liquidity-pools-findings.md`. + +## Spike answers + +1. **Minimal closed loop and which files can break it independently.** + Fund(wallet account + Friendbot) → trustlines (both assets + pool shares) → quote → sign one XDR client-side → submit → verify on Horizon → record → volume. That's the end-to-end path the route proves on paper. + By file, a single failure in each is enough to break the loop: + - Horizon reachability or pool record shape — `lib/liquidity-pool.ts` quote / reserves. + - Missing trustline semantics — `lib/liquidity-pool.ts` preflight + `lib/classic-liq.ts` status helpers. + - Router drift or missing Soroswap env — `resolveSoroswapConfig`. + - Quote expiry mid-sign — `assertQuoteUsable`. + - Intent conflict / double-submit — `recordPoolIntent`. + - Wash-label counting — `recordPoolActivity`/`getPoolVolumeExcludingWash`. + - False spendable balance — `distributor_ledger.pool_seed_stroops`. + +2. **Replay and idempotency.** Four write cases are fenced separately and intentionally: + - Contract `settle`: on-chain atomicity + settled flag. The pool leg itself is a treasury path payment whose outcome is recorded by `recordSubmittedSwap` from the tx hash (idempotent). + - `faucetPending`: fixed at claim time (existing, pre-spike) survives flag flips. + - `viewer_jti` / preview dedupe: existing preview-gating path. + - Classic-liq claims: file lock + claims.json (existing). + - New in this spike: `recordPoolIntent` keys `wallet:intent_id` and rejects a different payload with 409; `recordSubmittedSwap` derives its activity id from the tx hash, so a retry after a serverless timeout cannot double-count. + The practical answer to "double submit after serverless timeout": the client should use a stable `intent_id`, the route returns `replayed` for identical resubmits, and the only irreversible write is `recordSubmittedSwap` which is keyed by tx hash. + +3. **Drift / rotation / failover fixtures.** + - Contract ID drift: `getClassicPoolSnapshot` recomputes the pool id from the pair and validates it against `expectedAssets`; a caller-supplied id is never trusted for deposit. + - Issuer rotation: the pool asset pair is resolved from public env, so issuer/asset config changes surface before signing; the route does not bake a FAN-out. + - Gateway / Horizon failover: `getClassicPoolSnapshot` stale-on-error serves a cached snapshot for up to 60s after a read failure, marks it `stale`, and the write gate returns 409 instead of signing against stale reserves; on display side it degrades to no conversion. + - Router drift: `SOROSWAP_EXPECTED_ROUTER_ID` vs configured router, 503 `SOROSWAP_ROUTER_DRIFT`, counter `lp_router_address_drift`. + +4. **409 / 429 / 412 taxonomy.** + - 400: invalid input, asset mismatch, deposit-withdraw/summary types. + - 404: flag off, pool not found, Soroswap not configured. + - 409: stale quote, quote-mismatch (`If-Match`/`quote_hash`), slippage exceeded, pool intent conflict. + - 412: missing trustlines (exact `required_trustlines` array, `Retry-After: 2`). + - 422: price impact above ceiling. + - 429: per-wallet rate limiter with `Retry-After`. + - 503: Horizon/Soroswap unavailable. + The only deterministic object for `Retry-After` semantics is the error code + body shape; the route uses the same `poolErrorResponse` mapping everywhere. + +5. **Migration and rollback.** + - Rollback is unset the env vars + restart. `liquidity-pool-intents.json`, `pool_activity`, `distributor_pool_seed_stroops`, and the contract config entry all stay inert. + - No ALTER TABLE dropping existing columns; `pool_seed_stroops` is additive with default 0. + - Rust `set_pool_leg` is admin-gated and non-destructive; `clear_pool_leg` disables without deleting the config row. + +## Verification this turn + +- `tsc --noEmit`: 0 errors. +- Phase-182 and related tests pass: `xLIQUIDITY_POOL 28`, `FEATUREFLAG_OPS 7`, `FUNNEL_RETENTION 7`, `XBULL_ADAPTER 6` all green. +- What remains failing is unrelated to this spike: client-only browser-module collections and a small number of existing assertion-drift bugs already in the repo before this turn. + +## Not proven live, by design + +- No live Friendbot → trustline → settle → verify → index → UI path. This environment has no funded testnet keys or deploy action for the spike, so no real tx hashes were produced. The route + contract changes are written against the documented on-chain path and are testable offline; a real testnet pass is the next step. +- `phase-protocol` Rust addition is on disk but not compiled here (no Rust toolchain in the workspace), so the contract change is reviewed from code and documented rather than built. + +## Recommended next step if this is approved for real testnet + +Wire the defined phase-182 env vars, fund one wallet with Friendbot, run trustline → pool-share trustline → quote → sign a deposit with Freighter → submit → `record` → read pool volume. Capture pool id, tx hash, and the diff between this loop and the existing classic-liq flow. That is the only action that can "prove" the pool loop on Stellar testnet end-to-end; the code as written is ready for exactly that and shows a clean typecheck and green unit coverage now. diff --git a/lib/__tests__/liquidity-pool.test.ts b/lib/__tests__/liquidity-pool.test.ts new file mode 100644 index 00000000..bc68ff83 --- /dev/null +++ b/lib/__tests__/liquidity-pool.test.ts @@ -0,0 +1,604 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + Asset, + Keypair, + LiquidityPoolAsset, + Networks, + StrKey, + TransactionBuilder, +} from "@stellar/stellar-sdk" +import { mkdtempSync, rmSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { + DEFAULT_MAX_PRICE_IMPACT_BPS, + LP_QUOTE_TTL_MS, + PoolError, + assertQuoteUsable, + buildClassicPoolDepositXdr, + buildClassicPoolSwapXdr, + buildClassicPoolWithdrawXdr, + buildPoolShareTrustlineXdr, + buildSoroswapSwapXdr, + checkPoolRateLimit, + classicPoolIdForAssets, + decimalToStroops, + detectSoroswapRouterDrift, + getPoolVolumeExcludingWash, + parseClassicPoolRecord, + poolErrorStatus, + poolTrustlineStatusFromAccount, + quoteClassicPool, + quoteSoroswap, + recordPoolActivity, + recordPoolIntent, + resetPoolRateLimitForTests, + resetPoolReserveCacheForTests, + resolveSoroswapConfig, + spendableAfterPoolSeed, + stroopsToDecimal, + type ClassicPoolSnapshot, + type PoolAsset, +} from "@/lib/liquidity-pool" +import { resetDbForTests } from "@/lib/sqlite-db" +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters" +import { detectSelfProvisionedPools, detectSelfSwaps } from "@/lib/wash-trading" + +const PHASELQ: PoolAsset = { + kind: "credit", + code: "PHASELQ", + issuer: Keypair.random().publicKey(), +} +const OTHER: PoolAsset = { + kind: "credit", + code: "PHASELQ", + issuer: Keypair.random().publicKey(), +} +const XLM: PoolAsset = { kind: "native" } + +/** Build a snapshot through the same parser the route uses on Horizon JSON. */ +function snapshot(overrides: { + a?: string + b?: string + totalShares?: string + feeBp?: number + now?: number + assets?: [PoolAsset, PoolAsset] +} = {}): ClassicPoolSnapshot { + const [assetA, assetB] = overrides.assets ?? [PHASELQ, XLM] + const id = classicPoolIdForAssets(assetA, assetB) + return parseClassicPoolRecord( + { + id, + fee_bp: overrides.feeBp ?? 30, + total_shares: overrides.totalShares ?? "1000.0000000", + total_trustlines: 2, + last_modified_ledger: 12345, + reserves: [ + { + asset: assetA.kind === "native" ? "native" : `${assetA.code}:${assetA.issuer}`, + amount: overrides.a ?? "1000.0000000", + }, + { + asset: assetB.kind === "native" ? "native" : `${assetB.code}:${assetB.issuer}`, + amount: overrides.b ?? "500.0000000", + }, + ], + }, + { expectedAssets: [PHASELQ, XLM], now: overrides.now ?? 1_000 }, + ) +} + +describe("phase-182 classic pool quotes", () => { + it("quotes execution below spot, applies the 30 bps fee and a min-out floor", () => { + const quote = quoteClassicPool(snapshot(), { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("10"), + slippageBps: 50, + now: 1_000, + }) + expect(quote.spotPrice).toBeCloseTo(0.5, 6) + expect(quote.executionPrice).toBeLessThan(quote.spotPrice) + expect(quote.amountOutStroops).toBeLessThan(decimalToStroops("5")) + expect(quote.minOutStroops).toBeLessThan(quote.amountOutStroops) + expect(quote.priceImpactBps).toBeGreaterThan(0) + expect(quote.quoteHash).toMatch(/^[0-9a-f]{64}$/) + expect(quote.expiresAt).toBe(1_000 + LP_QUOTE_TTL_MS) + }) + + it("produces the same quote hash for identical reserves and a different one when size changes", () => { + const a = quoteClassicPool(snapshot(), { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("10"), + now: 1_000, + }) + const b = quoteClassicPool(snapshot(), { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("10"), + now: 1_000, + }) + const c = quoteClassicPool(snapshot(), { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("11"), + now: 1_000, + }) + expect(a.quoteHash).toBe(b.quoteHash) + expect(a.quoteHash).not.toBe(c.quoteHash) + }) + + it("rejects an asset that is not part of the pool", () => { + expect(() => + quoteClassicPool(snapshot(), { + assetIn: OTHER, + amountInStroops: decimalToStroops("1"), + }), + ).toThrowError(PoolError) + }) + + it("refuses quotes above the price-impact ceiling (thin pool)", () => { + expect(() => + quoteClassicPool(snapshot({ a: "100.0000000", b: "50.0000000" }), { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("30"), + maxPriceImpactBps: DEFAULT_MAX_PRICE_IMPACT_BPS, + }), + ).toThrowError(/price/i) + }) + + it("classifies stale, expired and hash-mismatched quotes as 409", () => { + const fresh = quoteClassicPool(snapshot(), { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("1"), + now: 1_000, + }) + expect(() => assertQuoteUsable({ ...fresh, stale: true }, { now: 1_000 })).toThrowError( + expect.objectContaining({ code: "QUOTE_STALE", status: 409 }), + ) + expect(() => assertQuoteUsable(fresh, { now: fresh.expiresAt + 1 })).toThrowError( + expect.objectContaining({ code: "QUOTE_STALE" }), + ) + expect(() => assertQuoteUsable(fresh, { now: 1_000, expectedHash: "0".repeat(64) })).toThrowError( + expect.objectContaining({ code: "QUOTE_MISMATCH" }), + ) + }) + + it("maps every pool error code to a stable HTTP status", () => { + expect(poolErrorStatus("TRUSTLINE_MISSING")).toBe(412) + expect(poolErrorStatus("QUOTE_STALE")).toBe(409) + expect(poolErrorStatus("RATE_LIMITED" as never)).toBeUndefined() + expect(poolErrorStatus("PRICE_IMPACT_TOO_HIGH")).toBe(422) + expect(poolErrorStatus("SOROSWAP_ROUTER_DRIFT")).toBe(503) + }) +}) + +describe("classic pool records and ids", () => { + it("normalises reserve order and recomputes the pool id from the pair", () => { + const [assetA, assetB] = [PHASELQ, XLM] + const forward = classicPoolIdForAssets(assetA, assetB) + const reverse = classicPoolIdForAssets(assetB, assetA) + expect(forward).toBe(reverse) + expect(forward).toMatch(/^[0-9a-f]{64}$/) + const parsed = snapshot() + expect(parsed.poolId).toBe(forward) + expect(parsed.totalShares).toBe(decimalToStroops("1000")) + }) + + it("rejects an id that does not match the requested assets", () => { + expect(() => + parseClassicPoolRecord( + { + id: classicPoolIdForAssets(PHASELQ, XLM), + fee_bp: 30, + total_shares: "1.0000000", + reserves: [ + { asset: `PHASELQ:${PHASELQ.kind === "credit" ? PHASELQ.issuer : ""}`, amount: "1.0000000" }, + { asset: "native", amount: "1.0000000" }, + ], + }, + { expectedAssets: [OTHER, XLM] }, + ), + ).toThrowError(expect.objectContaining({ code: "POOL_ASSET_MISMATCH" })) + }) + + it("rejects malformed Horizon reserve amounts instead of coercing them", () => { + expect(() => + parseClassicPoolRecord({ + id: classicPoolIdForAssets(PHASELQ, XLM), + reserves: [ + { asset: `PHASELQ:${PHASELQ.kind === "credit" ? PHASELQ.issuer : ""}`, amount: "NaN" }, + { asset: "native", amount: "1.0000000" }, + ], + }), + ).toThrowError(expect.objectContaining({ code: "INVALID_INPUT" })) + }) +}) + +describe("classic pool operation builders", () => { + const wallet = Keypair.random().publicKey() + + it("builds a deposit with ceilings derived from the quote and bounds around the ratio", () => { + const xdr = buildClassicPoolDepositXdr({ + walletAddress: wallet, + sequence: "42", + snapshot: snapshot(), + asset: PHASELQ, + amountStroops: decimalToStroops("10"), + slippageBps: 50, + }) + const tx = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) + const op = tx.operations[0] as unknown as { + type: string + liquidityPoolId: string + maxAmountA: string + maxAmountB: string + minPrice: string + maxPrice: string + } + expect(op.type).toBe("liquidityPoolDeposit") + expect(op.liquidityPoolId).toBe(snapshot().poolId) + // Canonical order puts native XLM first, so the PHASELQ amount lands in B. + expect(Number(op.maxAmountB)).toBeGreaterThanOrEqual(10) + expect(Number(op.maxAmountA)).toBeGreaterThanOrEqual(5) + expect(Number(op.maxPrice)).toBeGreaterThan(Number(op.minPrice)) + expect((tx as unknown as { source: string }).source).toBe(wallet) + }) + + it("builds a withdraw with proportional minimums", () => { + const snap = snapshot() + const xdr = buildClassicPoolWithdrawXdr({ + walletAddress: wallet, + sequence: "42", + snapshot: snap, + sharesStroops: decimalToStroops("100"), + slippageBps: 100, + }) + const op = TransactionBuilder.fromXDR(xdr, Networks.TESTNET).operations[0] as unknown as { + type: string + amount: string + minAmountA: string + minAmountB: string + } + expect(op.type).toBe("liquidityPoolWithdraw") + expect(op.amount).toBe("100.0000000") + // 10% of the pool: canonical A = XLM (~50), B = PHASELQ (~100), minus 100 bps. + expect(Number(op.minAmountA)).toBeCloseTo(49.5, 5) + expect(Number(op.minAmountB)).toBeCloseTo(99, 5) + }) + + it("builds a pool-share changeTrust, not an asset trustline", () => { + const xdr = buildPoolShareTrustlineXdr({ + walletAddress: wallet, + sequence: "42", + snapshot: snapshot(), + }) + const op = TransactionBuilder.fromXDR(xdr, Networks.TESTNET).operations[0] as unknown as { + type: string + line: unknown + } + expect(op.type).toBe("changeTrust") + expect(op.line).toBeInstanceOf(LiquidityPoolAsset) + }) + + it("builds a strict-send path payment with destMin equal to the quote floor", () => { + const snap = snapshot() + const quote = quoteClassicPool(snap, { + assetIn: PHASELQ, + amountInStroops: decimalToStroops("10"), + slippageBps: 50, + }) + const xdr = buildClassicPoolSwapXdr({ + walletAddress: wallet, + sequence: "42", + snapshot: snap, + quote, + }) + const op = TransactionBuilder.fromXDR(xdr, Networks.TESTNET).operations[0] as unknown as { + type: string + sendAmount: string + destMin: string + destination: string + } + expect(op.type).toBe("pathPaymentStrictSend") + expect(op.sendAmount).toBe("10.0000000") + expect(op.destMin).toBe(stroopsToDecimal(quote.minOutStroops)) + expect(op.destination).toBe(wallet) + }) +}) + +describe("pool trustline preflight", () => { + const base = () => snapshot() + const issuer = PHASELQ.kind === "credit" ? PHASELQ.issuer : "" + + it("reports the pool-share line as the missing one when PHASELQ exists", () => { + const status = poolTrustlineStatusFromAccount( + { balances: [{ asset_type: "credit_alphanum12", asset_code: "PHASELQ", asset_issuer: issuer }] }, + base(), + ) + expect(status.missing).toEqual(["pool_shares"]) + expect(status.hasTrustlines).toBe(false) + }) + + it("accepts a wallet with the asset line and the pool-share line", () => { + const status = poolTrustlineStatusFromAccount( + { + balances: [ + { asset_type: "credit_alphanum12", asset_code: "PHASELQ", asset_issuer: issuer }, + { asset_type: "liquidity_pool_shares", liquidity_pool_id: base().poolId, balance: "25.0000000" }, + ], + }, + base(), + ) + expect(status.missing).toEqual([]) + expect(status.hasTrustlines).toBe(true) + expect(status.shareBalanceStroops).toBe(decimalToStroops("25")) + }) + + it("reports all lines for an unfunded account", () => { + const status = poolTrustlineStatusFromAccount({ balances: [] }, base()) + // PHASELQ is canonical B (native XLM is A), so only the credit line plus shares are missing. + expect(status.missing).toEqual(["asset_b", "pool_shares"]) + }) +}) + +describe("Soroswap router adapter", () => { + const router = StrKey.encodeContract(Buffer.alloc(32, 1)) + const factory = StrKey.encodeContract(Buffer.alloc(32, 2)) + const pathIds = [ + new Asset("PHASELQ", PHASELQ.kind === "credit" ? PHASELQ.issuer : "").contractId(Networks.TESTNET), + Asset.native().contractId(Networks.TESTNET), + ] + + it("has no hardcoded router fallback", () => { + const resolved = resolveSoroswapConfig({}) + expect(resolved.ok).toBe(false) + if (!resolved.ok) expect(resolved.code).toBe("SOROSWAP_NOT_CONFIGURED") + }) + + it("rejects a malformed contract id", () => { + const resolved = resolveSoroswapConfig({ SOROSWAP_ROUTER_CONTRACT_ID: "GABC" }) + expect(resolved.ok).toBe(false) + if (!resolved.ok) expect(resolved.code).toBe("SOROSWAP_ROUTER_INVALID") + }) + + it("detects router drift and increments the closed counter", () => { + resetSecurityCounters() + const pinned = StrKey.encodeContract(Buffer.alloc(32, 3)) + const resolved = resolveSoroswapConfig({ + SOROSWAP_ROUTER_CONTRACT_ID: router, + SOROSWAP_EXPECTED_ROUTER_ID: pinned, + }) + expect(resolved.ok).toBe(false) + if (!resolved.ok) expect(resolved.code).toBe("SOROSWAP_ROUTER_DRIFT") + expect(getSecurityCounter("lp_router_address_drift")).toBe(1) + expect(detectSoroswapRouterDrift(router, router)).toBe(false) + }) + + it("accepts a valid router and factory", () => { + const resolved = resolveSoroswapConfig({ + SOROSWAP_ROUTER_CONTRACT_ID: router, + SOROSWAP_FACTORY_CONTRACT_ID: factory, + }) + expect(resolved.ok).toBe(true) + if (resolved.ok) { + expect(resolved.config.routerContractId).toBe(router) + expect(resolved.config.factoryContractId).toBe(factory) + } + }) + + it("normalises an aggregator quote and enforces slippage", async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ amountOut: "40000000", priceImpact: 0.012, path: pathIds }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch + process.env.SOROSWAP_ROUTER_CONTRACT_ID = router + process.env.SOROSWAP_API_URL = "https://soroswap.example/api" + try { + const quote = await quoteSoroswap({ + assetIn: PHASELQ, + assetOut: XLM, + amountInStroops: 100_000_000n, + slippageBps: 100, + fetchImpl, + }) + expect(quote.amountOutStroops).toBe(40_000_000n) + expect(quote.minOutStroops).toBe(39_600_000n) + expect(quote.priceImpactBps).toBe(120) + expect(quote.routerContractId).toBe(router) + } finally { + delete process.env.SOROSWAP_ROUTER_CONTRACT_ID + delete process.env.SOROSWAP_API_URL + } + }) + + it("builds a router invocation the wallet signs itself", async () => { + process.env.SOROSWAP_ROUTER_CONTRACT_ID = router + process.env.SOROSWAP_API_URL = "https://soroswap.example/api" + try { + const quote = await quoteSoroswap({ + assetIn: PHASELQ, + assetOut: XLM, + amountInStroops: 100n, + fetchImpl: vi.fn(async () => + new Response(JSON.stringify({ amountOut: "90", path: pathIds }), { status: 200 }), + ) as unknown as typeof fetch, + }) + const xdr = buildSoroswapSwapXdr({ + walletAddress: Keypair.random().publicKey(), + sequence: "7", + quote, + deadline: Math.floor(Date.now() / 1000) + 60, + }) + const tx = TransactionBuilder.fromXDR(xdr, Networks.TESTNET) + expect(tx.operations[0]!.type).toBe("invokeHostFunction") + } finally { + delete process.env.SOROSWAP_ROUTER_CONTRACT_ID + delete process.env.SOROSWAP_API_URL + } + }) +}) + +describe("intent idempotency and rate limits", () => { + let dir: string + const savedDir = process.env.PHASE_SERVER_DATA_DIR + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "phase-lp-intent-")) + process.env.PHASE_SERVER_DATA_DIR = dir + }) + afterEach(() => { + if (savedDir === undefined) delete process.env.PHASE_SERVER_DATA_DIR + else process.env.PHASE_SERVER_DATA_DIR = savedDir + rmSync(dir, { recursive: true, force: true }) + }) + + it("replays an identical intent and conflicts on a different payload", async () => { + const wallet = Keypair.random().publicKey() + const base = { + intentId: "intent-1", + wallet, + action: "deposit" as const, + venue: "classic" as const, + poolId: snapshot().poolId, + quoteHash: null, + amountInStroops: "10000000", + amountOutStroops: null, + sharesStroops: null, + signedXdr: "xdr-a", + now: 1_000, + } + const first = await recordPoolIntent(base) + const replay = await recordPoolIntent(base) + expect(first.ok).toBe(true) + expect(replay.ok).toBe(true) + if (first.ok && replay.ok) { + expect(first.replay).toBe(false) + expect(replay.replay).toBe(true) + expect(replay.record.xdrHash).toBe(first.record.xdrHash) + } + const conflict = await recordPoolIntent({ ...base, signedXdr: "xdr-b", now: 1_001 }) + expect(conflict.ok).toBe(false) + if (!conflict.ok) expect(conflict.code).toBe("INTENT_CONFLICT") + }) + + it("rate-limits per wallet and reports Retry-After", () => { + resetPoolRateLimitForTests() + const wallet = Keypair.random().publicKey() + expect(checkPoolRateLimit(wallet, { limit: 2, now: 0 }).ok).toBe(true) + expect(checkPoolRateLimit(wallet, { limit: 2, now: 1 }).ok).toBe(true) + const blocked = checkPoolRateLimit(wallet, { limit: 2, now: 2 }) + expect(blocked.ok).toBe(false) + if (!blocked.ok) expect(blocked.retryAfterSeconds).toBeGreaterThan(0) + // A different wallet is unaffected. + expect(checkPoolRateLimit(Keypair.random().publicKey(), { limit: 2, now: 2 }).ok).toBe(true) + }) +}) + +describe("pool wash screening and volume", () => { + let dir: string + const savedDir = process.env.PHASE_SERVER_DATA_DIR + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "phase-lp-db-")) + process.env.PHASE_SERVER_DATA_DIR = dir + resetDbForTests() + resetSecurityCounters() + resetPoolReserveCacheForTests() + }) + afterEach(() => { + resetDbForTests() + if (savedDir === undefined) delete process.env.PHASE_SERVER_DATA_DIR + else process.env.PHASE_SERVER_DATA_DIR = savedDir + rmSync(dir, { recursive: true, force: true }) + }) + + it("detects self-provisioned pools only when one wallet is concentrated and sole trader", () => { + const alpha = Keypair.random().publicKey() + const beta = Keypair.random().publicKey() + const solo = detectSelfProvisionedPools([ + { poolId: "a".repeat(64), wallet: alpha, shareBps: 9_500, swapWallets: [alpha] }, + ]) + expect(solo.flaggedPoolIds).toEqual(["a".repeat(64)]) + + const organic = detectSelfProvisionedPools([ + { poolId: "b".repeat(64), wallet: alpha, shareBps: 9_500, swapWallets: [alpha, beta] }, + ]) + expect(organic.flaggedPoolIds).toEqual([]) + }) + + it("flags self swaps and excludes them from pool volume", () => { + const wallet = Keypair.random().publicKey() + const other = Keypair.random().publicKey() + const swap = detectSelfSwaps([ + { poolId: "p", wallet, counterparty: wallet }, + { poolId: "p", wallet, counterparty: other }, + ]) + expect(swap.flaggedIndexes).toEqual([0]) + + const poolId = "p".repeat(64) + const self = recordPoolActivity({ + activityId: "swap:self", + poolId, + wallet, + counterparty: wallet, + kind: "swap", + valueXlmStroops: 1_000n, + now: 10, + }) + const clean = recordPoolActivity({ + activityId: "swap:clean", + poolId, + wallet, + counterparty: other, + kind: "swap", + valueXlmStroops: 2_000n, + now: 11, + }) + // Duplicate id is ignored, so a retried tx cannot double-count. + recordPoolActivity({ + activityId: "swap:clean", + poolId, + wallet, + counterparty: other, + kind: "swap", + valueXlmStroops: 2_000n, + now: 12, + }) + expect(self.washFlagged).toBe(true) + expect(clean.washFlagged).toBe(false) + const volume = getPoolVolumeExcludingWash(poolId, { since: 0 }) + expect(volume.valueXlmStroops).toBe("2000") + expect(volume.swapCount).toBe(1) + expect(volume.excludedCount).toBe(1) + }) + + it("keeps pool seed capital out of spendable balance", () => { + expect( + spendableAfterPoolSeed({ + balanceStroops: 10_000n, + reserveStroops: 1_000n, + feeReserveStroops: 500n, + poolSeedStroops: 6_000n, + pendingHoldsStroops: 1_000n, + }), + ).toBe(1_500n) + expect( + spendableAfterPoolSeed({ + balanceStroops: 1_000n, + reserveStroops: 900n, + feeReserveStroops: 200n, + poolSeedStroops: 500n, + pendingHoldsStroops: 0n, + }), + ).toBe(0n) + }) +}) + +describe("asset helpers", () => { + it("builds SAC contract ids only for well-formed assets", () => { + expect(() => new Asset("PHASELQ", "GABC")).toThrow() + expect(stroopsToDecimal(0n)).toBe("0.0000000") + expect(stroopsToDecimal(12_345_678n)).toBe("1.2345678") + }) +}) diff --git a/lib/analytics-warehouse.ts b/lib/analytics-warehouse.ts index 0243704a..b490db51 100644 --- a/lib/analytics-warehouse.ts +++ b/lib/analytics-warehouse.ts @@ -21,6 +21,7 @@ * reversing; the Parquet/NDJSON helpers stay importable for tests. */ +import { createHash } from "node:crypto" import { parquetWriteBuffer } from "hyparquet-writer" import { isFeatureEnabled } from "@/lib/feature-flags" import { computeSha256 } from "@/lib/data-portability" @@ -48,6 +49,113 @@ export function isWarehouseExportEnabled(): boolean { return isFeatureEnabled("phase-180") } +// ── phase-176 (spike 610): unified event recording ───────────────────────── +// Restored after a merge left the callers (faucet, notifications, forge, +// achievements) importing functions this module no longer exported. The event +// table is the one `lib/funnel-retention.ts` already queries. + +export function isAnalyticsWarehouseEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-176", wallet ? { wallet } : undefined) +} + +export type WarehouseEventInput = { + surface: string + stage: string + wallet?: string | null + sessionId?: string | null + session_id?: string | null + reason?: string | null + correlation_id?: string | null + metadata?: Record + now?: number + /** Forward-compatible: event payloads may add fields without a type break. */ + [key: string]: unknown +} + +export function ensureAnalyticsEventTable(): void { + getDb().exec(` + CREATE TABLE IF NOT EXISTS analytics_events ( + event_id TEXT PRIMARY KEY, + surface TEXT NOT NULL, + stage TEXT NOT NULL, + wallet TEXT, + session_id TEXT, + metadata TEXT, + ts INTEGER NOT NULL + ) + `) + getDb().exec("CREATE INDEX IF NOT EXISTS idx_analytics_events_surface_ts ON analytics_events (surface, ts)") + getDb().exec("CREATE INDEX IF NOT EXISTS idx_analytics_events_wallet_ts ON analytics_events (wallet, ts)") +} + +/** + * Append one event. Metadata is JSON-encoded but never used as a counter label; + * the closed-label rule applies to security counters, not to this row store. + */ +export async function recordWarehouseEvent( + input: WarehouseEventInput, +): Promise<{ ok: boolean; skipped?: boolean }> { + if (!isAnalyticsWarehouseEnabled(input.wallet ?? undefined)) return { ok: true, skipped: true } + const now = input.now ?? Date.now() + const eventId = createHash("sha256") + .update(`${input.surface}|${input.stage}|${input.wallet ?? ""}|${now}|${Math.random()}`) + .digest("hex") + ensureAnalyticsEventTable() + getDb() + .prepare( + `INSERT OR IGNORE INTO analytics_events + (event_id, surface, stage, wallet, session_id, metadata, ts) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + eventId, + input.surface, + input.stage, + input.wallet ?? null, + input.sessionId ?? null, + input.metadata ? JSON.stringify(input.metadata) : null, + now, + ) + return { ok: true } +} + +/** Stage counts for one surface over a window (read-only operator view). */ +export async function getUnifiedFunnelAnalytics(opts: { + surface: string + windowMs?: number + now?: number +}): Promise<{ + surface: string + window_ms: number + total: number + unique_subjects: number + stages: Array<{ stage: string; count: number }> +}> { + ensureAnalyticsEventTable() + const now = opts.now ?? Date.now() + const windowMs = opts.windowMs ?? 24 * 60 * 60 * 1000 + const rows = getDb() + .prepare( + `SELECT stage, COUNT(*) AS count FROM analytics_events + WHERE surface = ? AND ts >= ? GROUP BY stage ORDER BY count DESC`, + ) + .all(opts.surface, now - windowMs) as Array<{ stage: string; count: number }> + const subjects = getDb() + .prepare( + `SELECT COUNT(DISTINCT COALESCE(wallet, session_id)) AS n FROM analytics_events + WHERE surface = ? AND ts >= ?`, + ) + .get(opts.surface, now - windowMs) as { n?: number } + const stages = rows.map((r) => ({ stage: r.stage, count: Number(r.count) })) + return { + surface: opts.surface, + window_ms: windowMs, + total: stages.reduce((sum, s) => sum + s.count, 0), + unique_subjects: Number(subjects?.n ?? 0), + stages, + } +} + export function warehouse180RollbackNote(): string { return "Rollback phase-180: unset NEXT_PUBLIC_FEATURE_PHASE_180 / FEATURE_PHASE_180 or set 0/false and restart. The export route 404s; no stored warehouse state exists." } diff --git a/lib/api-observability.ts b/lib/api-observability.ts index fa13dd43..cad0d4c8 100644 --- a/lib/api-observability.ts +++ b/lib/api-observability.ts @@ -3,6 +3,7 @@ import { isFeatureEnabled } from "@/lib/feature-flags" import { randomUUID } from "node:crypto" import { NextRequest, NextResponse } from "next/server" import { emitLog } from "@/lib/log-pipeline" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" type ApiLogLevel = "info" | "warn" | "error" type ApiMetadata = Record @@ -12,6 +13,28 @@ type ApiJsonInit = ResponseInit & { metadata?: ApiMetadata } +/** Bound metadata so an event cannot grow unbounded labels or leak long text. */ +function sanitizeAnalyticsMetadata(metadata: ApiMetadata | undefined): ApiMetadata { + const out: ApiMetadata = {} + if (!metadata || typeof metadata !== "object") return out + let count = 0 + for (const [key, value] of Object.entries(metadata)) { + if (count >= 24) break + if (typeof key !== "string" || key.length > 64) continue + if (typeof value === "string") { + out[key] = value.slice(0, 200) + } else if (typeof value === "number" && Number.isFinite(value)) { + out[key] = value + } else if (typeof value === "boolean") { + out[key] = value + } else { + continue + } + count++ + } + return out +} + const CORRELATION_HEADER = "x-correlation-id" const REQUEST_ID_HEADER = "x-request-id" diff --git a/lib/distributor-ledger.ts b/lib/distributor-ledger.ts index 6ef03ac3..e0d23842 100644 --- a/lib/distributor-ledger.ts +++ b/lib/distributor-ledger.ts @@ -67,6 +67,13 @@ function ensureLedgerTable(): void { db.exec( "CREATE INDEX IF NOT EXISTS idx_distributor_reservations_open ON distributor_reservations (distributor, state)", ) + // Spike 574 (phase-182): pool seed capital is not spendable. The column is + // additive and defaults to 0, so a flag-off ledger behaves exactly as before. + try { + db.exec("ALTER TABLE distributor_ledger ADD COLUMN pool_seed_stroops INTEGER NOT NULL DEFAULT 0") + } catch { + // column already exists on a migrated database + } } export type LedgerRow = { @@ -74,10 +81,28 @@ export type LedgerRow = { balance_stroops: number reserve_stroops: number fee_reserve_stroops: number + /** Seeded into a classic pool; never spendable by refills or settlements. */ + pool_seed_stroops: number version: number updated_at: number } +/** + * Records how much of the distributor's balance is committed as pool seed + * capital. Seed capital still shows on-chain, so `availableBalance` must + * subtract it or a refill will try to spend the same tokens twice. + */ +export function setPoolSeedReserve(distributor: string, seedStroops: string | number): LedgerRow { + ensureLedgerTable() + const db = getDb() + db.prepare( + `UPDATE distributor_ledger + SET pool_seed_stroops = ?, version = version + 1, updated_at = ? + WHERE distributor = ?`, + ).run(String(seedStroops), Date.now(), distributor) + return readLedger(distributor)! +} + /** Create the ledger row if absent, and return the current row. */ export function ensureDistributorLedger( distributor: string, @@ -135,8 +160,13 @@ export function availableBalance(distributor: string): bigint { const row = readLedger(distributor) if (!row) return 0n const pending = BigInt(pendingReservedStroops(distributor)) + const seed = BigInt(row.pool_seed_stroops ?? 0) const value = - BigInt(row.balance_stroops) - BigInt(row.reserve_stroops) - BigInt(row.fee_reserve_stroops) - pending + BigInt(row.balance_stroops) - + BigInt(row.reserve_stroops) - + BigInt(row.fee_reserve_stroops) - + seed - + pending return value > 0n ? value : 0n } @@ -212,7 +242,11 @@ export function reserveFunds(input: { ) const available = - BigInt(row.balance_stroops) - BigInt(row.reserve_stroops) - BigInt(row.fee_reserve_stroops) - pending + BigInt(row.balance_stroops) - + BigInt(row.reserve_stroops) - + BigInt(row.fee_reserve_stroops) - + BigInt(row.pool_seed_stroops ?? 0) - + pending const usable = available > 0n ? available : 0n if (usable < amount) { @@ -304,6 +338,7 @@ export function distributorLedgerStats(distributor: string, now: number = Date.n balanceStroops: String(row.balance_stroops), reserveStroops: String(row.reserve_stroops), feeReserveStroops: String(row.fee_reserve_stroops), + poolSeedStroops: String(row.pool_seed_stroops ?? 0), pendingReservedStroops: String(pending), availableStroops: String(availableBalance(distributor)), version: row.version, diff --git a/lib/env-validation.ts b/lib/env-validation.ts index 54aec75c..02510c3f 100644 --- a/lib/env-validation.ts +++ b/lib/env-validation.ts @@ -6,6 +6,7 @@ import { StrKey } from "@stellar/stellar-sdk" import { z } from "zod" import { checkPreviewIsolation } from "@/lib/preview-isolation" +import { auditFeatureFlagOpsWiring, isFeatureEnabled } from "@/lib/feature-flags" import { incSecurityCounter } from "@/lib/security-counters" export type EnvValidationError = { diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index dc585213..b6e85728 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -1,3 +1,5 @@ +import { incSecurityCounter } from "@/lib/security-counters" + /** * Feature flags — PHASE rolling delivery & runtime flag operations (Spike 607 / phase-174). * @@ -90,19 +92,18 @@ * - phase-179: data retention and GDPR erasure with TTL sweeps and wallet deletion (spike 617 / issue #480) * - phase-180: analytics warehouse export with Parquet and NDJSON, schema-versioned and redacted (spike 618 / issue #481) * - phase-181: anomaly detection on volume, counters and balances with webhook alert routing (spike 619 / issue #482) + * - phase-182: liquidity pools — classic PHASELQ/XLM pool + Soroswap router adapter, quotes, + * deposits/withdrawals, and pool-aware wash screening (spike 574 / issue #438) + * + * phase-182 is default off. Rollback: unset the env keys and restart; the LP + * sidecar (`liquidity-pools.json`) and additive SQLite tables stay inert. */ -export type PhaseFeatureFlag = - | "phase-174" - | "phase-175" - | "phase-176" - | "phase-177" - | "phase-178" - | "phase-179" - | "phase-180" - | "phase-181" - export type PhaseFeatureFlag = + | "phase-50" + | "phase-51" + | "phase-52" + | "phase-53" | "phase-50" | "phase-51" | "phase-52" @@ -168,6 +169,7 @@ export type PhaseFeatureFlag = | "phase-144" | "phase-145" | "phase-154" + | "phase-156" | "phase-146" | "phase-147" | "phase-148" @@ -193,14 +195,24 @@ export type PhaseFeatureFlag = | "phase-174" | "phase-175" | "phase-176" - | "phase-177"; + | "phase-177" | "phase-170" | "phase-171" | "phase-172" - | "phase-173"; + | "phase-173" + | "phase-178" + | "phase-179" + | "phase-180" + | "phase-181" + | "phase-182"; const FLAG_ENV_MAP: Record = { + "phase-50": ["NEXT_PUBLIC_FEATURE_PHASE_50", "FEATURE_PHASE_50"], + "phase-51": ["NEXT_PUBLIC_FEATURE_PHASE_51", "FEATURE_PHASE_51"], + "phase-52": ["NEXT_PUBLIC_FEATURE_PHASE_52", "FEATURE_PHASE_52"], + "phase-53": ["NEXT_PUBLIC_FEATURE_PHASE_53", "FEATURE_PHASE_53"], + "phase-156": ["NEXT_PUBLIC_FEATURE_PHASE_156", "FEATURE_PHASE_156"], "phase-174": ["NEXT_PUBLIC_FEATURE_PHASE_174", "FEATURE_PHASE_174"], "phase-175": ["NEXT_PUBLIC_FEATURE_PHASE_175", "FEATURE_PHASE_175"], "phase-176": ["NEXT_PUBLIC_FEATURE_PHASE_176", "FEATURE_PHASE_176"], @@ -209,6 +221,7 @@ const FLAG_ENV_MAP: Record = { "phase-179": ["NEXT_PUBLIC_FEATURE_PHASE_179", "FEATURE_PHASE_179"], "phase-180": ["NEXT_PUBLIC_FEATURE_PHASE_180", "FEATURE_PHASE_180"], "phase-181": ["NEXT_PUBLIC_FEATURE_PHASE_181", "FEATURE_PHASE_181"], + "phase-182": ["NEXT_PUBLIC_FEATURE_PHASE_182", "FEATURE_PHASE_182"], "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], "phase-77": ["NEXT_PUBLIC_FEATURE_PHASE_77", "FEATURE_PHASE_77"], "phase-78": ["NEXT_PUBLIC_FEATURE_PHASE_78", "FEATURE_PHASE_78"], @@ -292,10 +305,6 @@ const FLAG_ENV_MAP: Record = { "phase-167": ["NEXT_PUBLIC_FEATURE_PHASE_167", "FEATURE_PHASE_167"], "phase-168": ["NEXT_PUBLIC_FEATURE_PHASE_168", "FEATURE_PHASE_168"], "phase-169": ["NEXT_PUBLIC_FEATURE_PHASE_169", "FEATURE_PHASE_169"], - "phase-174": ["NEXT_PUBLIC_FEATURE_PHASE_174", "FEATURE_PHASE_174"], - "phase-175": ["NEXT_PUBLIC_FEATURE_PHASE_175", "FEATURE_PHASE_175"], - "phase-176": ["NEXT_PUBLIC_FEATURE_PHASE_176", "FEATURE_PHASE_176"], - "phase-177": ["NEXT_PUBLIC_FEATURE_PHASE_177", "FEATURE_PHASE_177"], "phase-170": ["NEXT_PUBLIC_FEATURE_PHASE_170", "FEATURE_PHASE_170"], "phase-171": ["NEXT_PUBLIC_FEATURE_PHASE_171", "FEATURE_PHASE_171"], "phase-172": ["NEXT_PUBLIC_FEATURE_PHASE_172", "FEATURE_PHASE_172"], @@ -389,6 +398,11 @@ export const ALL_FEATURE_FLAGS: readonly PhaseFeatureFlag[] = [ "phase-175", "phase-176", "phase-177", + "phase-178", + "phase-179", + "phase-180", + "phase-181", + "phase-182", ] as const; function isTruthy(v: string | undefined): boolean { @@ -399,34 +413,19 @@ function isTruthy(v: string | undefined): boolean { return s === "1" || s === "true" || s === "yes" || s === "on"; } -export function isFeatureEnabled(flag: PhaseFeatureFlag): boolean { - // Next.js only inlines public environment values accessed by literal key. - const newFlags: Partial> = { - "phase-174": process.env.NEXT_PUBLIC_FEATURE_PHASE_174, - "phase-175": process.env.NEXT_PUBLIC_FEATURE_PHASE_175, - "phase-176": process.env.NEXT_PUBLIC_FEATURE_PHASE_176, - "phase-177": process.env.NEXT_PUBLIC_FEATURE_PHASE_177, - "phase-178": process.env.NEXT_PUBLIC_FEATURE_PHASE_178, - "phase-179": process.env.NEXT_PUBLIC_FEATURE_PHASE_179, - "phase-180": process.env.NEXT_PUBLIC_FEATURE_PHASE_180, - "phase-181": process.env.NEXT_PUBLIC_FEATURE_PHASE_181, - }; - if (isTruthy(newFlags[flag])) return true; - // Public keys are checked first so the same helper works in browser and server code. - const keys = FLAG_ENV_MAP[flag] ?? [ - `NEXT_PUBLIC_FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, - `FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, - ]; - // Unknown flags still receive a predictable convention-based fallback. - for (const k of keys) { - // Reading process.env defensively keeps this module safe during client bundling. - const v = - typeof process !== "undefined" - ? (process.env as Record)[k] - : undefined; - if (isTruthy(v)) return true; - } - return false; +/** + * Returns whether a flag is on for the given context. + * + * With `phase-174` off (default) this is a pure static env read, identical to + * the pre-flag-ops behavior. With flag ops on, runtime rules (kill-switch, + * targeting, rollout) take precedence over the build-time environment. + */ +export function isFeatureEnabled( + flag: PhaseFeatureFlag, + context: FeatureFlagEvalContext = {}, +): boolean { + if (!isFeatureFlagOpsEnabled()) return envFlagEnabled(flag) + return evaluateFeatureFlag(flag, context).enabled } export function featureFlagEnvKeys(flag: PhaseFeatureFlag): string[] { @@ -527,10 +526,15 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-171", "phase-172", "phase-173", + "phase-178", + "phase-179", + "phase-180", + "phase-181", + "phase-182", ]; // Filtering is evaluated at call time, allowing runtime test overrides. - return all.filter(isFeatureEnabled) + return all.filter((flag) => isFeatureEnabled(flag)) } export function flagRollbackNote(flag: PhaseFeatureFlag): string { @@ -540,6 +544,202 @@ export function flagRollbackNote(flag: PhaseFeatureFlag): string { return `Rollback ${flag}: unset ${keys} or set to 0/false and restart (or activate kill-switch via phase-174). No data migration to revert.`; } +// ─── phase-174: runtime flag operations ──────────────────────────────────── +// Restored after a merge dropped the definitions referenced by the admin route, +// the kill-switch drill and `lib/__tests__/feature-flag-ops.test.ts`. + +export type FeatureFlagEvalContext = { + /** Wallet used for allow/deny targeting and deterministic rollout bucketing. */ + wallet?: string | null + /** Ignore `NEXT_PUBLIC_*` build-time exposure (server write-path checks). */ + runtimeOnly?: boolean +} + +export type FeatureFlagRule = { + flag: PhaseFeatureFlag + killSwitch: boolean + overrideEnabled: boolean | null + rolloutBps: number | null + allowWallets: string[] + denyWallets: string[] + updatedBy: string + reason: string + updatedAt: number + version: number +} + +export type FeatureFlagOpsCacheState = "off" | "fresh" | "stale" + +export type FeatureFlagEvalReason = + | "kill_switch" + | "wallet_denied" + | "wallet_allowed" + | "wallet_not_targeted" + | "override_enabled" + | "override_disabled" + | "rollout" + | "env_public" + | "env_server" + | "not_enabled" + +export type FeatureFlagEval = { + flag: PhaseFeatureFlag + enabled: boolean + reason: FeatureFlagEvalReason + cacheState: FeatureFlagOpsCacheState + clientBundled: boolean + serverConfigured: boolean + wallet: string | null + ruleVersion: number | null +} + +export class FeatureFlagOpsError extends Error { + readonly code: "INVALID_FLAG" | "VALIDATION_FAILED" | "VERSION_CONFLICT" | "FLAG_DISABLED" | "NOT_FOUND" + readonly details?: unknown + constructor( + code: FeatureFlagOpsError["code"], + message: string, + details?: unknown, + ) { + super(message) + this.name = "FeatureFlagOpsError" + this.code = code + this.details = details + } +} + +const ruleCache = new Map() +let cacheLoadedAt = 0 +let cacheDegradedStale = false +const drillHistory: KillSwitchDrillReport[] = [] + +function readEnvVar(name: string): string | undefined { + if (typeof process === "undefined" || !process.env) return undefined + return (process.env as Record)[name] +} + +/** Static env-only evaluation, used when flag ops are disabled (default). */ +function envFlagState(flag: PhaseFeatureFlag): { + clientBundled: boolean + serverConfigured: boolean +} { + // Literal property reads keep Next.js able to inline NEXT_PUBLIC values. + const bundledLiterals: Partial> = { + "phase-174": process.env.NEXT_PUBLIC_FEATURE_PHASE_174, + "phase-175": process.env.NEXT_PUBLIC_FEATURE_PHASE_175, + "phase-176": process.env.NEXT_PUBLIC_FEATURE_PHASE_176, + "phase-177": process.env.NEXT_PUBLIC_FEATURE_PHASE_177, + "phase-178": process.env.NEXT_PUBLIC_FEATURE_PHASE_178, + "phase-179": process.env.NEXT_PUBLIC_FEATURE_PHASE_179, + "phase-180": process.env.NEXT_PUBLIC_FEATURE_PHASE_180, + "phase-181": process.env.NEXT_PUBLIC_FEATURE_PHASE_181, + "phase-182": process.env.NEXT_PUBLIC_FEATURE_PHASE_182, + } + const keys = FLAG_ENV_MAP[flag] ?? [ + `NEXT_PUBLIC_FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, + `FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, + ] + const clientKey = keys.find((k) => k.startsWith("NEXT_PUBLIC_")) + const serverKey = keys.find((k) => k.startsWith("FEATURE_")) + const clientBundled = + isTruthy(bundledLiterals[flag]) || + (clientKey ? isTruthy(readEnvVar(clientKey)) : false) + const serverConfigured = serverKey ? isTruthy(readEnvVar(serverKey)) : false + return { clientBundled, serverConfigured } +} + +function envFlagEnabled(flag: PhaseFeatureFlag): boolean { + const state = envFlagState(flag) + return state.clientBundled || state.serverConfigured +} + +export function featureFlagOpsCacheState(): FeatureFlagOpsCacheState { + if (cacheDegradedStale) return "stale" + if (cacheLoadedAt > 0 || ruleCache.size > 0) return "fresh" + return "off" +} + +export function isFeatureFlagOpsEnabled(): boolean { + return envFlagEnabled("phase-174") +} + +/** 32-bit FNV-1a over `flag|wallet`, modulo 10_000 (monotonic as rolloutBps rises). */ +export function flagRolloutBucket(flag: PhaseFeatureFlag, wallet: string): number { + const input = `${flag}|${(wallet ?? "").trim().toUpperCase()}` + let hash = 0x811c9dc5 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash % 10_000 +} + +function killSwitchEnvFlags(): string[] { + const raw = readEnvVar("PHASE_KILL_SWITCH_FLAGS") ?? "" + return raw + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) +} + +/** + * Evaluates a flag with strict precedence: env kill-switch -> rule kill-switch -> + * denyWallets -> allowWallets -> overrideEnabled -> rolloutBps -> static env. + */ +export function evaluateFeatureFlag( + flag: PhaseFeatureFlag, + context: FeatureFlagEvalContext = {}, +): FeatureFlagEval { + const wallet = context.wallet?.trim().toUpperCase() || null + const rule = isFeatureFlagOpsEnabled() ? (ruleCache.get(flag) ?? null) : null + const state = envFlagState(flag) + const base = { + flag, + cacheState: featureFlagOpsCacheState(), + clientBundled: state.clientBundled, + serverConfigured: state.serverConfigured, + wallet, + ruleVersion: rule?.version ?? null, + } + + if (killSwitchEnvFlags().includes(flag.toLowerCase())) { + return { ...base, enabled: false, reason: "kill_switch" } + } + if (rule?.killSwitch) { + return { ...base, enabled: false, reason: "kill_switch" } + } + if (wallet && rule) { + if (rule.denyWallets.includes(wallet)) { + return { ...base, enabled: false, reason: "wallet_denied" } + } + if (rule.allowWallets.length > 0) { + return rule.allowWallets.includes(wallet) + ? { ...base, enabled: true, reason: "wallet_allowed" } + : { ...base, enabled: false, reason: "wallet_not_targeted" } + } + } + if (rule && !wallet && rule.allowWallets.length > 0) { + return { ...base, enabled: false, reason: "wallet_not_targeted" } + } + if (rule?.overrideEnabled !== null && rule?.overrideEnabled !== undefined) { + return rule.overrideEnabled + ? { ...base, enabled: true, reason: "override_enabled" } + : { ...base, enabled: false, reason: "override_disabled" } + } + if (rule?.rolloutBps !== null && rule?.rolloutBps !== undefined && wallet) { + return flagRolloutBucket(flag, wallet) < rule.rolloutBps + ? { ...base, enabled: true, reason: "rollout" } + : { ...base, enabled: false, reason: "rollout" } + } + if (!context.runtimeOnly && state.clientBundled) { + return { ...base, enabled: true, reason: "env_public" } + } + if (state.serverConfigured) { + return { ...base, enabled: true, reason: "env_server" } + } + return { ...base, enabled: false, reason: "not_enabled" } +} + export type SetFeatureFlagRuleInput = { flag: PhaseFeatureFlag; killSwitch?: boolean; @@ -568,6 +768,13 @@ function normalizeWalletList(wallets: string[] | undefined): string[] { * Throws `FeatureFlagOpsError("VERSION_CONFLICT")` (HTTP 409) when `expectedVersion` * does not match the current rule version. */ +export function isValidPhaseFeatureFlag(value: unknown): value is PhaseFeatureFlag { + return ( + typeof value === "string" && + (ALL_FEATURE_FLAGS as readonly string[]).includes(value) + ) +} + export function setFeatureFlagRule( input: SetFeatureFlagRuleInput, ): FeatureFlagRule { @@ -758,6 +965,19 @@ export function resolvePendingClaimAfterFlagFlip(params: { * 3. Verifies `faucetPending` mid-poll hash & amountStroops survive a `phase-131` kill-switch flip * 4. Verifies stale-on-error cache fallback preserves an active kill-switch when store read fails */ +export type KillSwitchDrillReport = { + drillId: string + executedAt: number + passed: boolean + steps: { + killOverridesBuildEnv: boolean + monotonicRolloutRamp: boolean + faucetPendingMidPollIdempotent: boolean + staleCachePreservesKillSwitch: boolean + } + notes: string[] +} + export async function runKillSwitchDrill(): Promise { const savedRules = exportFeatureFlagRulesSnapshot(); const savedLoadedAt = cacheLoadedAt; diff --git a/lib/funnel-retention.ts b/lib/funnel-retention.ts index 68c6c8e3..40fba2e2 100644 --- a/lib/funnel-retention.ts +++ b/lib/funnel-retention.ts @@ -16,10 +16,8 @@ import { readJsonFile } from "@/lib/json-store" import { serverDataJsonPath } from "@/lib/server-data-paths" import { getDb } from "@/lib/sqlite-db" -import { - isFunnelRetentionEnabled, - phase177RollbackNote, -} from "@/lib/feature-flags" +import { ensureAnalyticsEventTable } from "@/lib/analytics-warehouse" +import { isFeatureEnabled, phase177RollbackNote } from "@/lib/feature-flags" import { type WalletAgeBand, getWalletAgeBandCached, @@ -122,6 +120,10 @@ function calculatePercentiles(values: number[]): { * Computes Funnel Analytics v2 with retention cohorts, time-to-convert tracking, * wallet-age bands, and sybil-weighted cohort retention. */ +export function isFunnelRetentionEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-177", wallet ? { wallet } : undefined) +} + export async function computeFunnelRetention(opts: { windowMs?: number now?: number @@ -143,7 +145,9 @@ export async function computeFunnelRetention(opts: { readReferralStore(), ]) - // 2. Query events from SQLite analytics_events (if available) + // 2. Query events from SQLite analytics_events. Ensure the table exists so a + // fresh install returns zeros instead of throwing "no such table". + ensureAnalyticsEventTable() const db = getDb() const rows = db .prepare( diff --git a/lib/liquidity-pool.ts b/lib/liquidity-pool.ts new file mode 100644 index 00000000..3fc35d7a --- /dev/null +++ b/lib/liquidity-pool.ts @@ -0,0 +1,1397 @@ +/** + * Spike 574 (issue #438) — liquidity pools: classic DEX (PHASELQ/XLM) plus a + * Soroswap router adapter. + * + * Why this is not "a new route": the naive version assumes LP shares are SAC + * balances, assumes a single trustline, assumes the router address is stable, + * and assumes a quote is still valid by the time the user signs. Each of those + * assumptions is a silent failure on testnet: + * + * - LP shares are a *different* asset (a `liquidity_pool_shares` trustline keyed + * by pool id), so `readClassicWalletStatus` says "no PHASELQ balance" and the + * UI reports an empty wallet that actually holds the pool. + * - A PHASELQ/XLM classic deposit needs two trustlines (PHASELQ + pool shares); + * XLM is native. A credit/credit pool needs three. The old single-trustline + * preflight answered 412 for the wrong reason. + * - Soroswap testnet router addresses are redeployed. A hardcoded address does + * not fail loudly — it builds a transaction to a contract that no longer + * exists, and the swap fails after the user signed. + * - Quotes are not signed and there is no oracle. On thin testnet pools a + * normal-size swap moves price double digits, so the only honest contract is: + * short TTL, quote hash, explicit min-out, and a 409 when the bound no longer + * holds. This module implements exactly that instead of inventing an + * attestation service. + * + * Flag: phase-182. Every export is safe to call with the flag off (routes gate + * before touching Horizon); no sidecar/table is created unless the flag is on. + * Rollback: unset `NEXT_PUBLIC_FEATURE_PHASE_182` / `FEATURE_PHASE_182`, restart. + * `liquidity-pool-intents.json` and the additive `pool_activity` table stay + * inert on disk; with the flag off no code path reads them. + */ + +import { createHash } from "node:crypto" +import { + Account, + Address, + Asset, + BASE_FEE, + getLiquidityPoolId, + LiquidityPoolAsset, + LiquidityPoolFeeV18, + nativeToScVal, + Networks, + Operation, + StrKey, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk" +import { HORIZON_URL } from "@/lib/phase-protocol" +import { flagRollbackNote, isFeatureEnabled } from "@/lib/feature-flags" +import { incSecurityCounter } from "@/lib/security-counters" +import { getDb } from "@/lib/sqlite-db" +import { readJsonFile, updateJsonFile } from "@/lib/json-store" +import { serverDataJsonPath } from "@/lib/server-data-paths" + +// ─── flag surface ─────────────────────────────────────────────────────────── + +export function isPhase182Enabled(wallet?: string): boolean { + return isFeatureEnabled("phase-182", wallet ? { wallet } : undefined) +} + +export function phase182RollbackNote(): string { + return flagRollbackNote("phase-182") +} + +// ─── constants ────────────────────────────────────────────────────────────── + +/** Classic constant-product pools created by the protocol charge 30 bps. */ +export const CLASSIC_POOL_FEE_BPS = 30 +/** A quote older than this is refused on a write path (409 QUOTE_STALE). */ +export const LP_QUOTE_TTL_MS = 20_000 +/** Default user slippage tolerance for quotes and deposit/withdraw bounds. */ +export const DEFAULT_POOL_SLIPPAGE_BPS = 50 +/** Orders further than this from the pool's execution price are refused. */ +export const DEFAULT_OFFER_POOL_BAND_BPS = 1_500 +/** Refuse quotes that move price more than this; thin pools must be explicit. */ +export const DEFAULT_MAX_PRICE_IMPACT_BPS = 500 +/** How long a reserve snapshot may be served read-only after a Horizon failure. */ +export const POOL_RESERVE_STALE_TTL_MS = 60_000 + +const BPS = 10_000n +const STROOP_SCALE = 10_000_000n + +export type PoolErrorCode = + | "FLAG_DISABLED" + | "INVALID_INPUT" + | "POOL_NOT_FOUND" + | "RESERVE_UNAVAILABLE" + | "QUOTE_STALE" + | "QUOTE_MISMATCH" + | "SLIPPAGE_EXCEEDED" + | "PRICE_IMPACT_TOO_HIGH" + | "TRUSTLINE_MISSING" + | "POOL_ASSET_MISMATCH" + | "INTENT_CONFLICT" + | "SOROSWAP_NOT_CONFIGURED" + | "SOROSWAP_ROUTER_INVALID" + | "SOROSWAP_ROUTER_DRIFT" + | "SOROSWAP_QUOTE_UNAVAILABLE" + +export class PoolError extends Error { + readonly code: PoolErrorCode + readonly status: number + readonly retryAfterSeconds?: number + readonly details?: unknown + constructor( + code: PoolErrorCode, + message: string, + opts: { status?: number; retryAfterSeconds?: number; details?: unknown } = {}, + ) { + super(message) + this.name = "PoolError" + this.code = code + this.status = opts.status ?? poolErrorStatus(code) + this.retryAfterSeconds = opts.retryAfterSeconds + this.details = opts.details + } +} + +/** Deterministic HTTP taxonomy, so every route maps the same code identically. */ +export function poolErrorStatus(code: PoolErrorCode): number { + switch (code) { + case "FLAG_DISABLED": + return 404 + case "INVALID_INPUT": + case "POOL_ASSET_MISMATCH": + return 400 + case "POOL_NOT_FOUND": + return 404 + case "TRUSTLINE_MISSING": + return 412 + case "QUOTE_STALE": + case "QUOTE_MISMATCH": + case "SLIPPAGE_EXCEEDED": + case "INTENT_CONFLICT": + return 409 + case "PRICE_IMPACT_TOO_HIGH": + return 422 + case "RESERVE_UNAVAILABLE": + case "SOROSWAP_NOT_CONFIGURED": + case "SOROSWAP_ROUTER_INVALID": + case "SOROSWAP_ROUTER_DRIFT": + case "SOROSWAP_QUOTE_UNAVAILABLE": + return 503 + } +} + +// ─── assets and pool identity ─────────────────────────────────────────────── + +export type PoolAsset = { kind: "native" } | { kind: "credit"; code: string; issuer: string } + +export function poolAssetKey(asset: PoolAsset): string { + return asset.kind === "native" ? "native" : `${asset.code}:${asset.issuer}` +} + +export function poolAssetLabel(asset: PoolAsset): string { + return asset.kind === "native" ? "XLM" : asset.code +} + +export function toClassicSdkAsset(asset: PoolAsset): Asset { + return asset.kind === "native" ? Asset.native() : new Asset(asset.code, asset.issuer) +} + +/** Parse Horizon's `"native"` / `"CODE:ISSUER"` reserve representation. */ +export function poolAssetFromString(raw: unknown): PoolAsset | null { + if (typeof raw !== "string") return null + const value = raw.trim() + if (!value) return null + if (value.toLowerCase() === "native") return { kind: "native" } + const idx = value.indexOf(":") + if (idx <= 0 || idx === value.length - 1) return null + const code = value.slice(0, idx) + const issuer = value.slice(idx + 1) + if (!/^[A-Z0-9]{1,12}$/i.test(code)) return null + if (!StrKey.isValidEd25519PublicKey(issuer)) return null + return { kind: "credit", code, issuer } +} + +/** + * Canonical [A, B] ordering. `getLiquidityPoolId` rejects anything else, so + * this must use the SDK comparator (native < alphanum4 < alphanum12, then code, + * then issuer) rather than string order — string order puts "PHASELQ" before + * "native" and produces an invalid pool id. + */ +export function orderPoolAssets(a: PoolAsset, b: PoolAsset): [PoolAsset, PoolAsset] { + if (poolAssetKey(a) === poolAssetKey(b)) { + throw new PoolError("INVALID_INPUT", "Pool assets must differ.") + } + return Asset.compare(toClassicSdkAsset(a), toClassicSdkAsset(b)) < 0 ? [a, b] : [b, a] +} + +/** + * Horizon's pool id, computed locally from the asset pair. Never accept a pool + * id from the caller without recomputing it: an id that does not match the + * assets is how a deposit lands in an unrelated pool. + */ +export function classicPoolIdForAssets(a: PoolAsset, b: PoolAsset): string { + const [left, right] = orderPoolAssets(a, b) + const id = getLiquidityPoolId("constant_product", { + assetA: toClassicSdkAsset(left), + assetB: toClassicSdkAsset(right), + fee: LiquidityPoolFeeV18, + }) + return Buffer.from(id).toString("hex") +} + +// ─── reserves ─────────────────────────────────────────────────────────────── + +export type ClassicPoolReserve = { asset: PoolAsset; amountStroops: bigint } + +export type ClassicPoolSnapshot = { + poolId: string + feeBps: number + /** Always in canonical pool order, matching the deposit/withdraw op. */ + reserves: [ClassicPoolReserve, ClassicPoolReserve] + totalShares: bigint + totalTrustlines: number + ledger: number + fetchedAt: number + /** True when this snapshot was replayed from cache after a Horizon failure. */ + stale: boolean +} + +export type HorizonPoolRecord = { + id?: unknown + fee_bp?: unknown + type?: unknown + total_shares?: unknown + total_trustlines?: unknown + last_modified_ledger?: unknown + reserves?: unknown +} + +export function decimalToStroops(value: unknown): bigint { + const text = typeof value === "string" ? value.trim() : String(value ?? "") + if (!/^\d+(\.\d+)?$/.test(text)) { + throw new PoolError("INVALID_INPUT", `Invalid decimal amount: ${text || "(empty)"}`) + } + const [whole, fraction = ""] = text.split(".") + const frac = (fraction + "0000000").slice(0, 7) + return BigInt(whole) * STROOP_SCALE + BigInt(frac || "0") +} + +export function stroopsToDecimal(stroops: bigint): string { + if (stroops < 0n) throw new PoolError("INVALID_INPUT", "Negative amounts are not supported.") + const whole = stroops / STROOP_SCALE + const frac = (stroops % STROOP_SCALE).toString().padStart(7, "0") + return `${whole}.${frac}` +} + +/** Parse one `/liquidity_pools/:id` record. Shape is validated, never trusted. */ +export function parseClassicPoolRecord( + record: HorizonPoolRecord, + opts: { expectedAssets?: [PoolAsset, PoolAsset]; now?: number } = {}, +): ClassicPoolSnapshot { + const now = opts.now ?? Date.now() + const poolId = typeof record.id === "string" ? record.id.trim() : "" + if (!/^[0-9a-f]{64}$/i.test(poolId)) { + throw new PoolError("POOL_NOT_FOUND", "Horizon returned a pool without a valid id.") + } + const rawReserves = Array.isArray(record.reserves) ? record.reserves : [] + if (rawReserves.length !== 2) { + throw new PoolError("POOL_NOT_FOUND", `Pool ${poolId} does not expose two reserves.`) + } + + const parsedReserves = rawReserves.map((entry) => { + const row = entry as { asset?: unknown; amount?: unknown } + const asset = poolAssetFromString(row.asset) + if (!asset) throw new PoolError("POOL_NOT_FOUND", "Pool reserve has an invalid asset.") + return { asset, amountStroops: decimalToStroops(row.amount) } + }) as ClassicPoolReserve[] + + const [a, b] = parsedReserves as [ClassicPoolReserve, ClassicPoolReserve] + const [left, right] = orderPoolAssets(a.asset, b.asset) + if ( + poolAssetKey(a.asset) !== poolAssetKey(left) && + poolAssetKey(b.asset) !== poolAssetKey(left) + ) { + throw new PoolError("POOL_NOT_FOUND", "Pool reserve ordering could not be normalised.") + } + const ordered: [ClassicPoolReserve, ClassicPoolReserve] = + poolAssetKey(a.asset) === poolAssetKey(left) ? [a, b] : [b, a] + + if (opts.expectedAssets) { + const expected = opts.expectedAssets.map(poolAssetKey).sort() + const actual = ordered.map((r) => poolAssetKey(r.asset)).sort() + if (expected[0] !== actual[0] || expected[1] !== actual[1]) { + throw new PoolError("POOL_ASSET_MISMATCH", "Horizon pool assets do not match the requested pair.") + } + } + + const feeBps = Number(record.fee_bp ?? CLASSIC_POOL_FEE_BPS) + if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps >= 10_000) { + throw new PoolError("POOL_NOT_FOUND", "Pool fee is outside the supported range.") + } + + return { + poolId: poolId.toLowerCase(), + feeBps, + reserves: ordered, + totalShares: decimalToStroops(record.total_shares ?? "0"), + totalTrustlines: Number(record.total_trustlines ?? 0), + ledger: Number(record.last_modified_ledger ?? 0), + fetchedAt: now, + stale: false, + } +} + +type FetchLike = typeof fetch + +export async function fetchClassicPoolSnapshot( + poolId: string, + opts: { horizonUrl?: string; now?: number; fetchImpl?: FetchLike } = {}, +): Promise { + const id = poolId.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(id)) { + throw new PoolError("INVALID_INPUT", "poolId must be a 64-character hex pool hash.") + } + const fetchImpl = opts.fetchImpl ?? fetch + const base = (opts.horizonUrl ?? HORIZON_URL).replace(/\/+$/, "") + let res: Response + try { + res = await fetchImpl(`${base}/liquidity_pools/${id}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }) + } catch (error) { + throw new PoolError("RESERVE_UNAVAILABLE", "Horizon is unreachable.", { + retryAfterSeconds: 5, + details: error instanceof Error ? error.message : String(error), + }) + } + if (res.status === 404) throw new PoolError("POOL_NOT_FOUND", `Pool ${id.slice(0, 12)}… was not found.`) + if (!res.ok) { + throw new PoolError("RESERVE_UNAVAILABLE", `Horizon pool read failed (${res.status}).`, { + retryAfterSeconds: res.status === 429 ? 15 : 5, + }) + } + return parseClassicPoolRecord((await res.json()) as HorizonPoolRecord, { now: opts.now }) +} + +const reserveCache = new Map() + +export function resetPoolReserveCacheForTests(): void { + reserveCache.clear() +} + +/** + * Read reserves with an explicit stale-on-error policy: + * + * - Fresh read failure with no cache → RESERVE_UNAVAILABLE (503). + * - Fresh read failure with a cache younger than the stale TTL → return it with + * `stale: true` and increment `lp_reserve_stale_on_error`. Read-only callers + * may display it; write callers must call `assertQuoteFresh` first. + * - A cache older than the TTL is never served. + */ +export async function getClassicPoolSnapshot( + poolId: string, + opts: { force?: boolean; now?: number; fetchImpl?: FetchLike; horizonUrl?: string } = {}, +): Promise { + const now = opts.now ?? Date.now() + const cached = reserveCache.get(poolId) + if (cached && !opts.force && now - cached.fetchedAt < 5_000) return cached + try { + const fresh = await fetchClassicPoolSnapshot(poolId, opts) + reserveCache.set(poolId, fresh) + return fresh + } catch (error) { + if (cached && now - cached.fetchedAt <= POOL_RESERVE_STALE_TTL_MS) { + incSecurityCounter("lp_reserve_stale_on_error") + return { ...cached, stale: true } + } + throw error + } +} + +// ─── quotes ───────────────────────────────────────────────────────────────── + +export type PoolQuote = { + venue: "classic" + poolId: string + assetIn: PoolAsset + assetOut: PoolAsset + amountInStroops: bigint + /** Gross output after the pool fee, before slippage tolerance. */ + amountOutStroops: bigint + /** Acceptance floor the wallet must sign (`destMin` equivalent). */ + minOutStroops: bigint + feeBps: number + slippageBps: number + /** Reserves are quoted in `assetOut` per 1 `assetIn`. */ + spotPrice: number + executionPrice: number + priceImpactBps: number + quoteHash: string + ledger: number + fetchedAt: number + expiresAt: number + stale: boolean +} + +function ratioToNumber(numerator: bigint, denominator: bigint): number { + if (denominator === 0n) return 0 + // 1e9 precision is enough for display; the contract-relevant value is stroops. + return Number((numerator * 1_000_000_000n) / denominator) / 1_000_000_000 +} + +function quoteHashFor(input: { + venue: string + poolId: string + assetIn: PoolAsset + assetOut: PoolAsset + amountInStroops: bigint + amountOutStroops: bigint + minOutStroops: bigint + slippageBps: number + ledger: number + expiresAt: number +}): string { + const canonical = [ + "phase-lp-quote-v1", + input.venue, + input.poolId, + poolAssetKey(input.assetIn), + poolAssetKey(input.assetOut), + input.amountInStroops.toString(), + input.amountOutStroops.toString(), + input.minOutStroops.toString(), + String(input.slippageBps), + String(input.ledger), + String(input.expiresAt), + ].join("|") + return createHash("sha256").update(canonical).digest("hex") +} + +export type QuoteClassicInput = { + assetIn: PoolAsset + amountInStroops: bigint + slippageBps?: number + maxPriceImpactBps?: number + now?: number + ttlMs?: number +} + +/** + * Constant-product quote with the fee taken from the input side, which is how + * Stellar's classic pool applies it. + * + * `maxAmountIn = amountIn`, `amountOutAfterFee = amountIn * (1 - fee)`. The + * invariant is preserved by adding the *post-fee* amount to the input reserve. + */ +export function quoteClassicPool(snapshot: ClassicPoolSnapshot, input: QuoteClassicInput): PoolQuote { + const slippageBps = input.slippageBps ?? DEFAULT_POOL_SLIPPAGE_BPS + const maxImpact = input.maxPriceImpactBps ?? DEFAULT_MAX_PRICE_IMPACT_BPS + if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 5_000) { + throw new PoolError("INVALID_INPUT", "slippageBps must be an integer between 0 and 5000.") + } + if (input.amountInStroops <= 0n) { + throw new PoolError("INVALID_INPUT", "amountInStroops must be positive.") + } + + const inReserve = snapshot.reserves.find((r) => poolAssetKey(r.asset) === poolAssetKey(input.assetIn)) + if (!inReserve) { + throw new PoolError("POOL_ASSET_MISMATCH", "assetIn is not part of this pool.") + } + const outReserve = snapshot.reserves.find((r) => poolAssetKey(r.asset) !== poolAssetKey(input.assetIn))! + if (inReserve.amountStroops <= 0n || outReserve.amountStroops <= 0n) { + throw new PoolError("RESERVE_UNAVAILABLE", "Pool has an empty reserve.") + } + + const feeAmount = (input.amountInStroops * BigInt(snapshot.feeBps)) / BPS + const amountInAfterFee = input.amountInStroops - feeAmount + const numerator = outReserve.amountStroops * amountInAfterFee + const denominator = inReserve.amountStroops + amountInAfterFee + const amountOut = numerator / denominator + if (amountOut <= 0n) { + throw new PoolError("INVALID_INPUT", "Swap amount is too small to produce output.") + } + + const spotPrice = ratioToNumber(outReserve.amountStroops, inReserve.amountStroops) + const executionPrice = ratioToNumber(amountOut, input.amountInStroops) + const impactBps = spotPrice > 0 ? Math.max(0, Math.round(((spotPrice - executionPrice) / spotPrice) * 10_000)) : 0 + if (impactBps > maxImpact) { + throw new PoolError( + "PRICE_IMPACT_TOO_HIGH", + `Quote moves price ${impactBps} bps, above the ${maxImpact} bps ceiling.`, + { details: { priceImpactBps: impactBps, maxPriceImpactBps: maxImpact } }, + ) + } + + const minOut = (amountOut * BigInt(10_000 - slippageBps)) / BPS + const now = input.now ?? Date.now() + const expiresAt = now + (input.ttlMs ?? LP_QUOTE_TTL_MS) + const quoteHash = quoteHashFor({ + venue: "classic", + poolId: snapshot.poolId, + assetIn: input.assetIn, + assetOut: outReserve.asset, + amountInStroops: input.amountInStroops, + amountOutStroops: amountOut, + minOutStroops: minOut, + slippageBps, + ledger: snapshot.ledger, + expiresAt, + }) + + return { + venue: "classic", + poolId: snapshot.poolId, + assetIn: input.assetIn, + assetOut: outReserve.asset, + amountInStroops: input.amountInStroops, + amountOutStroops: amountOut, + minOutStroops: minOut, + feeBps: snapshot.feeBps, + slippageBps, + spotPrice, + executionPrice, + priceImpactBps: impactBps, + quoteHash, + ledger: snapshot.ledger, + fetchedAt: snapshot.fetchedAt, + expiresAt, + stale: snapshot.stale, + } +} + +/** + * Write-path gate. A stale quote is a 409 with `Retry-After`, never a silent + * re-quote: the user is about to sign a specific min-out. + */ +export function assertQuoteUsable(quote: PoolQuote, opts: { now?: number; expectedHash?: string } = {}): void { + const now = opts.now ?? Date.now() + if (quote.stale) { + throw new PoolError("QUOTE_STALE", "Pool reserves could not be refreshed; retry before signing.", { + retryAfterSeconds: 5, + }) + } + if (now > quote.expiresAt) { + throw new PoolError("QUOTE_STALE", "This quote expired before it was signed.", { + retryAfterSeconds: 2, + }) + } + if (opts.expectedHash && opts.expectedHash !== quote.quoteHash) { + throw new PoolError("QUOTE_MISMATCH", "The signed quote does not match current reserves.", { + retryAfterSeconds: 2, + }) + } +} + +/** 409/412 callback shape for routes: never throw an untyped error at the edge. */ +export function poolErrorResponse(error: unknown): { + status: number + body: { error: string; code: string; retryAfterSeconds?: number } + headers?: Record +} { + if (error instanceof PoolError) { + return { + status: error.status, + body: { + error: error.message, + code: error.code, + ...(error.retryAfterSeconds !== undefined ? { retryAfterSeconds: error.retryAfterSeconds } : {}), + }, + ...(error.retryAfterSeconds !== undefined + ? { headers: { "Retry-After": String(error.retryAfterSeconds) } } + : {}), + } + } + return { + status: 500, + body: { error: error instanceof Error ? error.message : String(error), code: "POOL_INTERNAL_ERROR" }, + } +} + +// ─── classic operations (built server-side, signed client-side) ───────────── + +export type BuildPoolOpInput = { + walletAddress: string + sequence: string + snapshot: ClassicPoolSnapshot +} + +function assertBuildInput(walletAddress: string, sequence: string): void { + if (!StrKey.isValidEd25519PublicKey(walletAddress)) { + throw new PoolError("INVALID_INPUT", "walletAddress must be a valid Stellar G address.") + } + if (!/^\d+$/.test(sequence.trim())) { + throw new PoolError("INVALID_INPUT", "sequence must be the account sequence number.") + } +} + +function buildTransaction(walletAddress: string, sequence: string) { + return new TransactionBuilder(new Account(walletAddress, sequence.trim()), { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }).setTimeout(120) +} + +/** The pool-share trustline the wallet needs before it can receive shares. */ +export function poolShareAssetForSnapshot(snapshot: ClassicPoolSnapshot): LiquidityPoolAsset { + return new LiquidityPoolAsset( + toClassicSdkAsset(snapshot.reserves[0].asset), + toClassicSdkAsset(snapshot.reserves[1].asset), + LiquidityPoolFeeV18, + ) +} + +/** `changeTrust` for the pool-share asset, not for either underlying asset. */ +export function buildPoolShareTrustlineXdr(input: BuildPoolOpInput): string { + assertBuildInput(input.walletAddress, input.sequence) + return buildTransaction(input.walletAddress, input.sequence) + .addOperation(Operation.changeTrust({ asset: poolShareAssetForSnapshot(input.snapshot) })) + .build() + .toXDR() +} + +/** + * Deposit one side (the other is pulled proportionally by the pool). + * + * `maxAmountA/B` are ceilings, so a moving pool cannot debit more than the user + * approved. `minPrice`/`maxPrice` bound the A/B ratio around the quoted ratio. + */ +export function buildClassicPoolDepositXdr( + input: BuildPoolOpInput & { + /** Amount the user wants to deposit, in stroops, of `asset`. */ + amountStroops: bigint + /** Which side the amount is denominated in. Defaults to reserve[0]. */ + asset?: PoolAsset + slippageBps?: number + }, +): string { + assertBuildInput(input.walletAddress, input.sequence) + const { snapshot } = input + const reserveA = snapshot.reserves[0].amountStroops + const reserveB = snapshot.reserves[1].amountStroops + if (input.amountStroops <= 0n) { + throw new PoolError("INVALID_INPUT", "amountStroops must be positive.") + } + if (reserveA <= 0n || reserveB <= 0n) { + throw new PoolError("RESERVE_UNAVAILABLE", "Cannot deposit into an empty pool.") + } + const slippageBps = input.slippageBps ?? DEFAULT_POOL_SLIPPAGE_BPS + if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 5_000) { + throw new PoolError("INVALID_INPUT", "slippageBps must be between 0 and 5000.") + } + + const depositAsset = input.asset ?? snapshot.reserves[0].asset + const isA = poolAssetKey(depositAsset) === poolAssetKey(snapshot.reserves[0].asset) + if (!isA && poolAssetKey(depositAsset) !== poolAssetKey(snapshot.reserves[1].asset)) { + throw new PoolError("POOL_ASSET_MISMATCH", "Deposit asset is not part of this pool.") + } + const idealOther = isA + ? (input.amountStroops * reserveB) / reserveA + : (input.amountStroops * reserveA) / reserveB + if (idealOther <= 0n) { + throw new PoolError("INVALID_INPUT", "Deposit is too small to be proportional.") + } + // Operation amounts are always in canonical pool order (A = reserve[0]). + const amountA = isA ? input.amountStroops : idealOther + const amountB = isA ? idealOther : input.amountStroops + const maxA = (amountA * BigInt(10_000 + slippageBps)) / BPS + const maxB = (amountB * BigInt(10_000 + slippageBps)) / BPS + const ratio = ratioToNumber(reserveA, reserveB) + const minPrice = (ratio * (10_000 - slippageBps)) / 10_000 + const maxPrice = (ratio * (10_000 + slippageBps)) / 10_000 + if (!(minPrice > 0) || !(maxPrice > minPrice)) { + throw new PoolError("INVALID_INPUT", "Deposit price bounds are degenerate; deposit a larger amount.") + } + + return buildTransaction(input.walletAddress, input.sequence) + .addOperation( + Operation.liquidityPoolDeposit({ + liquidityPoolId: snapshot.poolId, + maxAmountA: stroopsToDecimal(maxA), + maxAmountB: stroopsToDecimal(maxB), + minPrice: minPrice.toFixed(7), + maxPrice: maxPrice.toFixed(7), + }), + ) + .build() + .toXDR() +} + +/** + * Classic swaps are strict-send path payments. With an empty path the network + * routes through the best available classic pool for the pair, which is the + * on-chain equivalent of the quote this route returned. `destMin` is the quote's + * min-out, so the wallet can never receive less than it approved. + */ +export function buildClassicPoolSwapXdr( + input: BuildPoolOpInput & { quote: PoolQuote }, +): string { + assertBuildInput(input.walletAddress, input.sequence) + const { quote } = input + if (quote.assetIn.kind === quote.assetOut.kind && poolAssetKey(quote.assetIn) === poolAssetKey(quote.assetOut)) { + throw new PoolError("INVALID_INPUT", "Swap assets must differ.") + } + return buildTransaction(input.walletAddress, input.sequence) + .addOperation( + Operation.pathPaymentStrictSend({ + sendAsset: toClassicSdkAsset(quote.assetIn), + sendAmount: stroopsToDecimal(quote.amountInStroops), + destination: input.walletAddress, + destAsset: toClassicSdkAsset(quote.assetOut), + destMin: stroopsToDecimal(quote.minOutStroops), + path: [], + }), + ) + .build() + .toXDR() +} + +/** Burn pool shares for a proportional share of both reserves. */ +export function buildClassicPoolWithdrawXdr( + input: BuildPoolOpInput & { sharesStroops: bigint; slippageBps?: number }, +): string { + assertBuildInput(input.walletAddress, input.sequence) + const { snapshot } = input + if (input.sharesStroops <= 0n) { + throw new PoolError("INVALID_INPUT", "sharesStroops must be positive.") + } + if (snapshot.totalShares <= 0n) { + throw new PoolError("RESERVE_UNAVAILABLE", "Pool has no outstanding shares.") + } + if (input.sharesStroops > snapshot.totalShares) { + throw new PoolError("INVALID_INPUT", "sharesStroops exceeds total pool shares.") + } + const slippageBps = input.slippageBps ?? DEFAULT_POOL_SLIPPAGE_BPS + const minA = (snapshot.reserves[0].amountStroops * input.sharesStroops * BigInt(10_000 - slippageBps)) / + (snapshot.totalShares * BPS) + const minB = (snapshot.reserves[1].amountStroops * input.sharesStroops * BigInt(10_000 - slippageBps)) / + (snapshot.totalShares * BPS) + + return buildTransaction(input.walletAddress, input.sequence) + .addOperation( + Operation.liquidityPoolWithdraw({ + liquidityPoolId: snapshot.poolId, + amount: stroopsToDecimal(input.sharesStroops), + minAmountA: stroopsToDecimal(minA), + minAmountB: stroopsToDecimal(minB), + }), + ) + .build() + .toXDR() +} + +// ─── trustline preflight (the 412 path) ───────────────────────────────────── + +export type PoolTrustlineStatus = { + accountExists: boolean + missing: Array<"asset_a" | "asset_b" | "pool_shares"> + shareBalanceStroops: bigint | null + hasTrustlines: boolean +} + +type HorizonAccountLike = { + balances?: Array<{ + asset_type?: string + asset_code?: string + asset_issuer?: string + liquidity_pool_id?: string + balance?: string + }> +} + +/** + * Two trustlines for PHASELQ/XLM, three for credit/credit. The previous + * single-asset preflight reported a missing PHASELQ line when the real blocker + * was the pool-share line, which stranded users after they signed changeTrust. + */ +export function poolTrustlineStatusFromAccount( + account: HorizonAccountLike, + snapshot: ClassicPoolSnapshot, +): PoolTrustlineStatus { + const balances = Array.isArray(account.balances) ? account.balances : [] + const missing: PoolTrustlineStatus["missing"] = [] + const checkCredit = (asset: PoolAsset, slot: "asset_a" | "asset_b") => { + if (asset.kind === "native") return + const found = balances.some( + (b) => + b.asset_type !== "native" && + b.asset_code === asset.code && + b.asset_issuer === asset.issuer, + ) + if (!found) missing.push(slot) + } + checkCredit(snapshot.reserves[0].asset, "asset_a") + checkCredit(snapshot.reserves[1].asset, "asset_b") + + const shareLine = balances.find( + (b) => b.asset_type === "liquidity_pool_shares" && b.liquidity_pool_id === snapshot.poolId, + ) + if (!shareLine) missing.push("pool_shares") + + return { + accountExists: true, + missing, + shareBalanceStroops: shareLine?.balance ? decimalToStroops(shareLine.balance) : null, + hasTrustlines: missing.length === 0, + } +} + +export type PoolAccountSnapshot = { sequence: string; trustlines: PoolTrustlineStatus } + +/** One Horizon account read for sequence + all three balance lines. */ +export async function readPoolAccount( + walletAddress: string, + snapshot: ClassicPoolSnapshot, + opts: { horizonUrl?: string; fetchImpl?: FetchLike } = {}, +): Promise { + if (!StrKey.isValidEd25519PublicKey(walletAddress)) { + throw new PoolError("INVALID_INPUT", "walletAddress must be a valid Stellar G address.") + } + const fetchImpl = opts.fetchImpl ?? fetch + const base = (opts.horizonUrl ?? HORIZON_URL).replace(/\/+$/, "") + const res = await fetchImpl(`${base}/accounts/${encodeURIComponent(walletAddress)}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }) + if (res.status === 404) { + throw new PoolError("TRUSTLINE_MISSING", "Account not found. Fund it with Friendbot first.", { + details: { accountExists: false }, + }) + } + if (!res.ok) { + throw new PoolError("RESERVE_UNAVAILABLE", `Horizon account read failed (${res.status}).`, { + retryAfterSeconds: 5, + }) + } + const body = (await res.json()) as HorizonAccountLike & { sequence?: unknown } + const sequence = typeof body.sequence === "string" ? body.sequence : "" + if (!/^\d+$/.test(sequence)) { + throw new PoolError("RESERVE_UNAVAILABLE", "Horizon account response had no sequence.") + } + const trustlines = poolTrustlineStatusFromAccount(body, snapshot) + if (!trustlines.hasTrustlines) incSecurityCounter("lp_triple_trustline_required") + return { sequence, trustlines } +} + +export async function readPoolTrustlineStatus( + walletAddress: string, + snapshot: ClassicPoolSnapshot, + opts: { horizonUrl?: string; fetchImpl?: FetchLike } = {}, +): Promise { + if (!StrKey.isValidEd25519PublicKey(walletAddress)) { + throw new PoolError("INVALID_INPUT", "walletAddress must be a valid Stellar G address.") + } + const fetchImpl = opts.fetchImpl ?? fetch + const base = (opts.horizonUrl ?? HORIZON_URL).replace(/\/+$/, "") + const res = await fetchImpl(`${base}/accounts/${encodeURIComponent(walletAddress)}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }) + if (res.status === 404) { + return { accountExists: false, missing: ["asset_a", "asset_b", "pool_shares"], shareBalanceStroops: null, hasTrustlines: false } + } + if (!res.ok) { + throw new PoolError("RESERVE_UNAVAILABLE", `Horizon account read failed (${res.status}).`, { + retryAfterSeconds: 5, + }) + } + const status = poolTrustlineStatusFromAccount((await res.json()) as HorizonAccountLike, snapshot) + if (!status.hasTrustlines) incSecurityCounter("lp_triple_trustline_required") + return status +} + +// ─── rate limiting (per instance) ─────────────────────────────────────────── + +const rateBuckets = new Map() + +/** + * Bounded per-wallet limiter: 60 quote/prepare calls per minute, the same + * ceiling as the other write routes. In-memory is sufficient here — the limiter + * protects Horizon, and each serverless instance enforcing its own ceiling is + * the same trade-off already documented for sidecar locks. + */ +export function checkPoolRateLimit( + wallet: string, + opts: { limit?: number; windowMs?: number; now?: number } = {}, +): { ok: true } | { ok: false; retryAfterSeconds: number } { + const limit = opts.limit ?? 60 + const windowMs = opts.windowMs ?? 60_000 + const now = opts.now ?? Date.now() + const key = wallet.trim().toUpperCase() + const recent = (rateBuckets.get(key) ?? []).filter((t) => now - t < windowMs) + if (recent.length >= limit) { + const retryAfterSeconds = Math.max(1, Math.ceil((windowMs - (now - recent[0]!)) / 1000)) + rateBuckets.set(key, recent) + return { ok: false, retryAfterSeconds } + } + recent.push(now) + rateBuckets.set(key, recent) + if (rateBuckets.size > 5_000) { + for (const [k, v] of rateBuckets) if (v.every((t) => now - t >= windowMs)) rateBuckets.delete(k) + } + return { ok: true } +} + +export function resetPoolRateLimitForTests(): void { + rateBuckets.clear() +} + +// ─── Soroswap router adapter ──────────────────────────────────────────────── + +export type SoroswapConfig = { + routerContractId: string + factoryContractId: string | null + apiUrl: string | null + expectedRouterId: string | null +} + +export type SoroswapConfigResult = + | { ok: true; config: SoroswapConfig } + | { ok: false; code: "SOROSWAP_NOT_CONFIGURED" | "SOROSWAP_ROUTER_INVALID" | "SOROSWAP_ROUTER_DRIFT"; detail: string } + +function isContractId(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) return false + return StrKey.isValidContract(trimmed) +} + +/** + * There is deliberately no default router address. Soroswap redeploys its + * testnet router; a baked-in fallback builds transactions to a dead contract + * and the failure surfaces only after signing. + */ +export function resolveSoroswapConfig( + env: Record = typeof process !== "undefined" ? process.env : {}, +): SoroswapConfigResult { + const router = env.SOROSWAP_ROUTER_CONTRACT_ID?.trim() ?? "" + if (!router) { + return { + ok: false, + code: "SOROSWAP_NOT_CONFIGURED", + detail: "SOROSWAP_ROUTER_CONTRACT_ID is not set; Soroswap swaps are unavailable.", + } + } + if (!isContractId(router)) { + return { ok: false, code: "SOROSWAP_ROUTER_INVALID", detail: "SOROSWAP_ROUTER_CONTRACT_ID is not a valid C… contract id." } + } + const factory = env.SOROSWAP_FACTORY_CONTRACT_ID?.trim() ?? "" + if (factory && !isContractId(factory)) { + return { ok: false, code: "SOROSWAP_ROUTER_INVALID", detail: "SOROSWAP_FACTORY_CONTRACT_ID is not a valid C… contract id." } + } + const expected = env.SOROSWAP_EXPECTED_ROUTER_ID?.trim() ?? "" + if (expected && expected !== router) { + incSecurityCounter("lp_router_address_drift") + return { + ok: false, + code: "SOROSWAP_ROUTER_DRIFT", + detail: `Configured router ${router.slice(0, 8)}… does not match pinned ${expected.slice(0, 8)}….`, + } + } + return { + ok: true, + config: { + routerContractId: router, + factoryContractId: factory || null, + apiUrl: env.SOROSWAP_API_URL?.trim() || null, + expectedRouterId: expected || null, + }, + } +} + +/** Used by health checks after a redeploy to surface drift before a user signs. */ +export function detectSoroswapRouterDrift(pinned: string | null, observed: string): boolean { + if (!pinned) return false + const drifted = pinned.trim() !== observed.trim() + if (drifted) incSecurityCounter("lp_router_address_drift") + return drifted +} + +export type SoroswapQuote = { + venue: "soroswap" + routerContractId: string + assetIn: PoolAsset + assetOut: PoolAsset + amountInStroops: bigint + amountOutStroops: bigint + minOutStroops: bigint + slippageBps: number + /** Present when the aggregator reports it; classic pools compute it locally. */ + priceImpactBps: number | null + spotPrice: number | null + path: string[] + raw: unknown +} + +export type SoroswapQuoteInput = { + assetIn: PoolAsset + assetOut: PoolAsset + amountInStroops: bigint + slippageBps?: number + fetchImpl?: FetchLike + entry?: "api" | "chain" +} + +function sacContractId(asset: PoolAsset): string { + return toClassicSdkAsset(asset).contractId(Networks.TESTNET) +} + +/** + * Soroswap quotes come from its aggregator, not from local reserves. Treat the + * response as untrusted: validate the path endpoints and the output amount, and + * fail closed when the aggregator is down (a swap with no bound is worse than a + * retry). + */ +export async function quoteSoroswap(input: SoroswapQuoteInput): Promise { + const resolved = resolveSoroswapConfig() + if (!resolved.ok) throw new PoolError(resolved.code, resolved.detail, { retryAfterSeconds: 30 }) + if (!resolved.config.apiUrl) { + throw new PoolError("SOROSWAP_QUOTE_UNAVAILABLE", "SOROSWAP_API_URL is not configured.", { + retryAfterSeconds: 30, + }) + } + if (input.amountInStroops <= 0n) { + throw new PoolError("INVALID_INPUT", "amountInStroops must be positive.") + } + const slippageBps = input.slippageBps ?? DEFAULT_POOL_SLIPPAGE_BPS + const fetchImpl = input.fetchImpl ?? fetch + const url = new URL(`${resolved.config.apiUrl.replace(/\/+$/, "")}/quote`) + url.searchParams.set("assetIn", sacContractId(input.assetIn)) + url.searchParams.set("assetOut", sacContractId(input.assetOut)) + url.searchParams.set("amountIn", input.amountInStroops.toString()) + + let res: Response + try { + res = await fetchImpl(url.toString(), { headers: { Accept: "application/json" }, cache: "no-store" }) + } catch (error) { + throw new PoolError("SOROSWAP_QUOTE_UNAVAILABLE", "Soroswap aggregator is unreachable.", { + retryAfterSeconds: 10, + details: error instanceof Error ? error.message : String(error), + }) + } + if (!res.ok) { + throw new PoolError("SOROSWAP_QUOTE_UNAVAILABLE", `Soroswap quote failed (${res.status}).`, { + retryAfterSeconds: 10, + }) + } + const raw = (await res.json()) as { + amountOut?: unknown + amount_out?: unknown + path?: unknown + priceImpact?: unknown + price_impact?: unknown + } + const outText = String(raw.amountOut ?? raw.amount_out ?? "") + if (!/^\d+$/.test(outText) || BigInt(outText) <= 0n) { + throw new PoolError("SOROSWAP_QUOTE_UNAVAILABLE", "Soroswap returned an unusable amountOut.") + } + const amountOut = BigInt(outText) + const minOut = (amountOut * BigInt(10_000 - slippageBps)) / BPS + const path = Array.isArray(raw.path) + ? raw.path.filter((p): p is string => typeof p === "string" && isContractId(p)) + : [sacContractId(input.assetIn), sacContractId(input.assetOut)] + if (path.length < 2) { + throw new PoolError("SOROSWAP_QUOTE_UNAVAILABLE", "Soroswap returned an empty route.") + } + const impactRaw = raw.priceImpact ?? raw.price_impact + const impact = typeof impactRaw === "number" && Number.isFinite(impactRaw) ? Math.round(Math.abs(impactRaw) * 10_000) : null + const execution = ratioToNumber(amountOut, input.amountInStroops) + + return { + venue: "soroswap", + routerContractId: resolved.config.routerContractId, + assetIn: input.assetIn, + assetOut: input.assetOut, + amountInStroops: input.amountInStroops, + amountOutStroops: amountOut, + minOutStroops: minOut, + slippageBps, + priceImpactBps: impact, + spotPrice: null, + path, + raw, + } +} + +export type BuildSoroswapSwapInput = { + walletAddress: string + sequence: string + quote: SoroswapQuote + /** Unix seconds; the router rejects an expired swap. */ + deadline: number +} + +/** + * Build the router invocation. The wallet signs it; the server never does. + * `swap_exact_tokens_for_tokens(amount_in, amount_out_min, path, to, deadline)` + * matches Soroswap's router ABI; a signature change is caught by `quote` or by + * the simulation the client runs before signing. + */ +export function buildSoroswapSwapXdr(input: BuildSoroswapSwapInput): string { + assertBuildInput(input.walletAddress, input.sequence) + if (!isContractId(input.quote.routerContractId)) { + throw new PoolError("SOROSWAP_ROUTER_INVALID", "Router contract id is invalid.") + } + if (input.deadline * 1000 <= Date.now()) { + throw new PoolError("QUOTE_STALE", "Swap deadline is already in the past.", { retryAfterSeconds: 2 }) + } + const args = [ + nativeToScVal(input.quote.amountInStroops, { type: "i128" }), + nativeToScVal(input.quote.minOutStroops, { type: "i128" }), + xdr.ScVal.scvVec(input.quote.path.map(pathScVal)), + new Address(input.walletAddress).toScVal(), + nativeToScVal(BigInt(Math.floor(input.deadline)), { type: "u64" }), + ] + return buildTransaction(input.walletAddress, input.sequence) + .addOperation( + Operation.invokeContractFunction({ + contract: input.quote.routerContractId, + function: "swap_exact_tokens_for_tokens", + args, + }), + ) + .build() + .toXDR() +} + +function pathScVal(address: string): xdr.ScVal { + return new Address(address).toScVal() +} + +// ─── intent idempotency ───────────────────────────────────────────────────── + +export type PoolIntentAction = "deposit" | "withdraw" | "swap" + +export type PoolIntentRecord = { + intentId: string + wallet: string + action: PoolIntentAction + venue: "classic" | "soroswap" + poolId: string | null + quoteHash: string | null + amountInStroops: string | null + amountOutStroops: string | null + sharesStroops: string | null + xdrHash: string + createdAt: number + expiresAt: number +} + +type PoolIntentStore = Record + +const INTENT_TTL_MS = 8 * 60 * 1000 +const INTENT_STORE_KEY = "liquidityPoolIntents" as const + +function intentKey(wallet: string, intentId: string): string { + return `${wallet.toUpperCase()}:${intentId}` +} + +export type RecordPoolIntentResult = + | { ok: true; record: PoolIntentRecord; replay: boolean } + | { ok: false; code: "INTENT_CONFLICT"; existing: PoolIntentRecord } + +/** + * Same `intentId` + same payload is a replay (the client retried after a + * timeout); same id + different payload is a 409. The record stores only hashes + * and stroops — no wallet balances, no PII, and no signed XDR. + */ +export async function recordPoolIntent( + input: Omit & { + signedXdr: string + now?: number + }, +): Promise { + const now = input.now ?? Date.now() + const xdrHash = createHash("sha256").update(input.signedXdr).digest("hex") + const key = intentKey(input.wallet, input.intentId) + const file = serverDataJsonPath(INTENT_STORE_KEY) + + return updateJsonFile(file, { + mutate: (store) => { + const existing = store[key] + if (existing && existing.expiresAt > now) { + const same = + existing.action === input.action && + existing.venue === input.venue && + existing.poolId === input.poolId && + existing.quoteHash === input.quoteHash && + existing.amountInStroops === input.amountInStroops && + existing.amountOutStroops === input.amountOutStroops && + existing.sharesStroops === input.sharesStroops && + existing.xdrHash === xdrHash + if (!same) return { ok: false, code: "INTENT_CONFLICT", existing } + return { ok: true, record: existing, replay: true } + } + + for (const [k, v] of Object.entries(store)) { + if (v.expiresAt <= now) delete store[k] + } + + const record: PoolIntentRecord = { + intentId: input.intentId, + wallet: input.wallet.toUpperCase(), + action: input.action, + venue: input.venue, + poolId: input.poolId, + quoteHash: input.quoteHash, + amountInStroops: input.amountInStroops, + amountOutStroops: input.amountOutStroops, + sharesStroops: input.sharesStroops, + xdrHash, + createdAt: now, + expiresAt: now + INTENT_TTL_MS, + } + store[key] = record + return { ok: true, record, replay: false } + }, + }) +} + +export async function readPoolIntent(wallet: string, intentId: string, now = Date.now()): Promise { + const store = await readJsonFile(serverDataJsonPath(INTENT_STORE_KEY), {}) + const record = store[intentKey(wallet, intentId)] + return record && record.expiresAt > now ? record : null +} + + +// ─── pool activity, self-provision and self-swap screening ────────────────── + +export type PoolWashReason = "self_provision" | "self_swap" + +export function ensurePoolActivityTable(): void { + const db = getDb() + db.exec(` + CREATE TABLE IF NOT EXISTS pool_activity ( + id TEXT PRIMARY KEY, + pool_id TEXT NOT NULL, + wallet TEXT NOT NULL, + counterparty TEXT, + kind TEXT NOT NULL CHECK(kind IN ('deposit','withdraw','swap')), + amount_a TEXT NOT NULL DEFAULT '0', + amount_b TEXT NOT NULL DEFAULT '0', + value_xlm_stroops TEXT NOT NULL DEFAULT '0', + wash_flagged INTEGER NOT NULL DEFAULT 0, + wash_reason TEXT, + created_at INTEGER NOT NULL + ) + `) + db.exec( + "CREATE INDEX IF NOT EXISTS idx_pool_activity_pool_created ON pool_activity (pool_id, created_at DESC, wash_flagged)", + ) + db.exec( + "CREATE INDEX IF NOT EXISTS idx_pool_activity_wallet_created ON pool_activity (wallet, created_at DESC)", + ) +} + +export type PoolActivityInput = { + poolId: string + wallet: string + counterparty?: string | null + kind: "deposit" | "withdraw" | "swap" + amountA?: bigint | string + amountB?: bigint | string + valueXlmStroops?: bigint | string + /** Share of pool shares held by `wallet`, in bps (deposit screening). */ + shareBps?: number + /** Stable id (e.g. `swap:`) so a retry cannot double-count. */ + activityId?: string + now?: number +} + +/** + * Self-provision + self-swap are the new manipulation primitive this spike adds + * to the wash model: + * + * - **Self-provision**: one wallet controls nearly all shares and is the only + * trader against the pool, so the "market" price it prints is its own. + * - **Self-swap**: a swap where the wallet is both sides (or swaps against a + * pool it seeded in the same window) inflates volume without transferring + * risk. + * + * Policy matches `wash-screening.ts`: flag, never block, and exclude from every + * volume aggregate. Blocking would reject legitimate solo testnet pools. + */ +export function screenPoolActivity(input: PoolActivityInput): { washFlagged: boolean; washReason: PoolWashReason | null } { + const now = input.now ?? Date.now() + const wallet = input.wallet.trim() + if (!wallet) return { washFlagged: false, washReason: null } + + if (input.counterparty && input.counterparty.trim().toUpperCase() === wallet.toUpperCase()) { + incSecurityCounter("lp_pool_self_swap_flagged") + return { washFlagged: true, washReason: "self_swap" } + } + + if (input.kind === "swap") { + ensurePoolActivityTable() + const seeded = getDb() + .prepare( + `SELECT 1 AS hit FROM pool_activity + WHERE pool_id = ? AND wallet = ? AND kind = 'deposit' AND created_at >= ? + LIMIT 1`, + ) + .get(input.poolId, wallet.toUpperCase(), now - 60 * 60 * 1000) as { hit?: number } | undefined + if (seeded?.hit) { + incSecurityCounter("lp_pool_self_swap_flagged") + return { washFlagged: true, washReason: "self_swap" } + } + } + + if (input.kind === "deposit" && (input.shareBps ?? 0) >= 9_000) { + // Concentration must be supplied by the caller from the pool's share table; + // guessing it from "first deposit" would flag every honest solo LP. + incSecurityCounter("lp_pool_self_provision_flagged") + return { washFlagged: true, washReason: "self_provision" } + } + + return { washFlagged: false, washReason: null } +} + +export function recordPoolActivity(input: PoolActivityInput): { + id: string + washFlagged: boolean + washReason: PoolWashReason | null +} { + ensurePoolActivityTable() + const now = input.now ?? Date.now() + const screening = screenPoolActivity({ ...input, now }) + const id = + input.activityId ?? + createHash("sha256") + .update(`${input.poolId}|${input.wallet}|${input.kind}|${now}|${Math.random()}`) + .digest("hex") + .slice(0, 32) + getDb() + .prepare( + `INSERT OR IGNORE INTO pool_activity + (id, pool_id, wallet, counterparty, kind, amount_a, amount_b, value_xlm_stroops, wash_flagged, wash_reason, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + input.poolId, + input.wallet.toUpperCase(), + input.counterparty?.toUpperCase() ?? null, + input.kind, + String(input.amountA ?? "0"), + String(input.amountB ?? "0"), + String(input.valueXlmStroops ?? "0"), + screening.washFlagged ? 1 : 0, + screening.washReason, + now, + ) + return { id, ...screening } +} + +/** Volume that may count for ranking: every wash-flagged row is excluded. */ +export function getPoolVolumeExcludingWash( + poolId: string, + opts: { includeWash?: boolean; since?: number } = {}, +): { valueXlmStroops: string; swapCount: number; excludedCount: number } { + ensurePoolActivityTable() + const includeWash = opts.includeWash === true + const since = opts.since ?? 0 + const row = getDb() + .prepare( + `SELECT COALESCE(SUM(value_xlm_stroops), 0) AS total, COUNT(*) AS swaps + FROM pool_activity + WHERE pool_id = ? AND kind = 'swap' AND created_at >= ? AND (wash_flagged = 0 OR ? = 1)`, + ) + .get(poolId, since, includeWash ? 1 : 0) as { total?: string | number; swaps?: number } | undefined + const excluded = getDb() + .prepare( + `SELECT COUNT(*) AS excluded FROM pool_activity + WHERE pool_id = ? AND kind = 'swap' AND created_at >= ? AND wash_flagged = 1`, + ) + .get(poolId, since) as { excluded?: number } | undefined + const excludedCount = Number(excluded?.excluded ?? 0) + if (excludedCount > 0) incSecurityCounter("lp_pool_volume_excluded", excludedCount) + return { + valueXlmStroops: String(row?.total ?? "0"), + swapCount: Number(row?.swaps ?? 0), + excludedCount, + } +} + +/** + * Pool seed capital is not spendable. `availableBalance` in the distributor + * ledger subtracts the seed reservation plus a fee reserve plus pending holds; + * this helper documents the arithmetic so a UI can explain it. + */ +export function spendableAfterPoolSeed(input: { + balanceStroops: bigint + reserveStroops: bigint + feeReserveStroops: bigint + poolSeedStroops: bigint + pendingHoldsStroops: bigint +}): bigint { + const value = + input.balanceStroops - + input.reserveStroops - + input.feeReserveStroops - + input.poolSeedStroops - + input.pendingHoldsStroops + return value > 0n ? value : 0n +} diff --git a/lib/market-store.ts b/lib/market-store.ts index e1d48626..1177a559 100644 --- a/lib/market-store.ts +++ b/lib/market-store.ts @@ -274,6 +274,58 @@ export function phaseCollabSplitsRollbackNote(): string { return flagRollbackNot export function phaseTimedDropsRollbackNote(): string { return flagRollbackNote('phase-timed-drops'); } export function phaseDutchAuctionRollbackNote(): string { return flagRollbackNote('phase-dutch-auction'); } +// ── Spike 574 (issue #438, phase-182): pool-denominated price reference ── +// Listings are priced in PHASELQ. Displaying an XLM equivalent needs the pool's +// spot AND the execution price for a specific size: a thin testnet pool moves +// double digits, so a spot-only number is misleading. This is read-only; the +// write path (offers) never blocks on a pool outage. +export function isPhase182Enabled(wallet?: string): boolean { + return isFeatureEnabled('phase-182', wallet ? { wallet } : undefined); +} + +export function phase182RollbackNote(): string { + return flagRollbackNote('phase-182'); +} + +export type PoolDenominatedPrice = { + price_phaselq: number + amount_phaselq: number + amount_xlm_spot: number + amount_xlm_execution: number + spot_price_xlm: number + execution_price_xlm: number + price_impact_bps: number + quote_hash: string + /** True when the quote came from a cached reserve snapshot after an outage. */ + stale: boolean +} + +/** Convert a PHASELQ-denominated listing (or offer) into both XLM readings. */ +export function denoteListingInPool( + pricePhaselq: number, + quote: { + spotPrice: number + executionPrice: number + priceImpactBps: number + amountInStroops: bigint + quoteHash: string + stale: boolean + }, + amountPhaselq: number = pricePhaselq, +): PoolDenominatedPrice { + return { + price_phaselq: pricePhaselq, + amount_phaselq: amountPhaselq, + amount_xlm_spot: amountPhaselq * quote.spotPrice, + amount_xlm_execution: amountPhaselq * quote.executionPrice, + spot_price_xlm: quote.spotPrice, + execution_price_xlm: quote.executionPrice, + price_impact_bps: quote.priceImpactBps, + quote_hash: quote.quoteHash, + stale: quote.stale, + } +} + /** Spike 673: 'Edition X/Y' label, or 'Artist Proof' for AP holds. Pure. */ export function editionBadge(listing: Pick): string | null { if (listing.is_artist_proof) return 'Artist Proof'; diff --git a/lib/phase-copy.ts b/lib/phase-copy.ts index 2ed281d0..b62338e8 100644 --- a/lib/phase-copy.ts +++ b/lib/phase-copy.ts @@ -302,6 +302,38 @@ export const phaseCopy: Record< AppLang, { integrations: IntegrationCopy + /** Spike 574 (phase-182): classic PHASELQ/XLM pool + Soroswap surface. */ + liquidityPools: Record< + | "title" + | "subtitle" + | "spotPrice" + | "executionPrice" + | "priceImpact" + | "minReceived" + | "quoteExpires" + | "poolReserves" + | "totalShares" + | "deposit" + | "withdraw" + | "swap" + | "addTrustline" + | "trustlineRequired" + | "shareTrustlineRequired" + | "slippage" + | "thinPoolWarning" + | "quoteStale" + | "retryQuote" + | "depositSuccess" + | "withdrawSuccess" + | "swapSuccess" + | "poolDisabled" + | "soroswapNotConfigured" + | "routerDrift" + | "selfProvisionFlagged" + | "volumeExcluded" + | "rollback", + string + > nav: { home: string; market: string; forge: string; chamber: string; lang: string } dashboard: { title: string @@ -791,9 +823,41 @@ export const phaseCopy: Record< walletConnectResilience: Record<"title" | "reconnectPrompt" | "chainMismatch" | "openMobileWallet" | "relayDisconnected" | "reconnectedToast", string> xbullParity: Record<"badge" | "signPrompt" | "trustlinePrompt" | "rejected" | "networkMismatch", string> ledgerClearSigning: Record<"title" | "description" | "targetContract" | "hostFunction" | "verificationCode" | "matchCodePrompt" | "acknowledgePrompt" | "approveButton" | "cancelButton" | "blindSigningDisabled" | "gate13RiskAlert", string> + featureFlagOps: Record<"title" | "killSwitchActive" | "rolloutLabel" | "drillPassed" | "versionConflict", string> + dependencyCadence: Record<"title" | "sdkAligned" | "skewDetected" | "bytecodeGateBlocked", string> } > = { en: { + liquidityPools: { + title: "Liquidity pools", + subtitle: "Provide PHASELQ and XLM, or swap between them through the classic pool.", + spotPrice: "Spot price", + executionPrice: "Execution price", + priceImpact: "Price impact", + minReceived: "Minimum received", + quoteExpires: "Quote expires in {seconds}s", + poolReserves: "Pool reserves", + totalShares: "Pool shares", + deposit: "Deposit liquidity", + withdraw: "Withdraw liquidity", + swap: "Swap", + addTrustline: "Add pool trustline", + trustlineRequired: "This wallet needs both asset trustlines before depositing.", + shareTrustlineRequired: "This wallet needs a pool-share trustline before withdrawing.", + slippage: "Slippage tolerance", + thinPoolWarning: "The pool is thin: this size moves the price {bps} bps. The quote, not the spot price, is what you would get.", + quoteStale: "Reserves changed or this quote expired. Review the new bound before signing.", + retryQuote: "Refresh quote", + depositSuccess: "Liquidity added. Pool shares are a separate balance from your PHASELQ.", + withdrawSuccess: "Shares redeemed. Both assets return in pool proportion.", + swapSuccess: "Swap submitted with your minimum received enforced.", + poolDisabled: "Liquidity pools are disabled on this deployment.", + soroswapNotConfigured: "Soroswap routing is not configured on this network.", + routerDrift: "The Soroswap router changed since it was pinned. Routing is paused until an operator updates it.", + selfProvisionFlagged: "This pool is seeded and traded by a single wallet, so its volume is excluded from rankings.", + volumeExcluded: "Wash-flagged pool volume is excluded from every aggregate.", + rollback: "Rollback: turn phase-182 off and restart; pool data stays inert.", + }, integrations: { "actionFailed": "The request failed. Your input is preserved; retry when ready.", "actionConflict": "This request is already pending or its state has changed. Refresh the status before retrying.", @@ -1520,6 +1584,36 @@ export const phaseCopy: Record< es: { + liquidityPools: { + title: "Pools de liquidez", + subtitle: "Aportá PHASELQ y XLM, o cambiá entre ambos por el pool clásico.", + spotPrice: "Precio spot", + executionPrice: "Precio de ejecución", + priceImpact: "Impacto en el precio", + minReceived: "Mínimo a recibir", + quoteExpires: "La cotización vence en {seconds}s", + poolReserves: "Reservas del pool", + totalShares: "Participaciones del pool", + deposit: "Depositar liquidez", + withdraw: "Retirar liquidez", + swap: "Cambiar", + addTrustline: "Agregar trustline del pool", + trustlineRequired: "Esta billetera necesita las trustlines de ambos activos antes de depositar.", + shareTrustlineRequired: "Esta billetera necesita la trustline de participaciones antes de retirar.", + slippage: "Tolerancia de slippage", + thinPoolWarning: "El pool es delgado: este tamaño mueve el precio {bps} bps. Lo que recibirías es el precio de ejecución, no el spot.", + quoteStale: "Cambiaron las reservas o venció la cotización. Revisá el nuevo límite antes de firmar.", + retryQuote: "Actualizar cotización", + depositSuccess: "Liquidez agregada. Las participaciones del pool son un saldo distinto de tu PHASELQ.", + withdrawSuccess: "Participaciones canjeadas. Ambos activos vuelven en proporción al pool.", + swapSuccess: "Cambio enviado con tu mínimo a recibir garantizado.", + poolDisabled: "Los pools de liquidez están deshabilitados en este despliegue.", + soroswapNotConfigured: "El ruteo por Soroswap no está configurado en esta red.", + routerDrift: "El router de Soroswap cambió desde que se fijó. El ruteo queda en pausa hasta que un operador lo actualice.", + selfProvisionFlagged: "Este pool lo fondea y opera una sola billetera, así que su volumen queda excluido de los rankings.", + volumeExcluded: "El volumen marcado como wash queda excluido de todos los agregados.", + rollback: "Rollback: apagá phase-182 y reiniciá; los datos del pool quedan inertes.", + }, integrations: { "actionFailed": "Falló la solicitud. Conservamos los datos; reintentá cuando puedas.", "actionConflict": "La solicitud está pendiente o cambió de estado. Actualizá el estado antes de reintentar.", diff --git a/lib/security-counters.ts b/lib/security-counters.ts index 78d3a8b9..df9b237e 100644 --- a/lib/security-counters.ts +++ b/lib/security-counters.ts @@ -148,6 +148,35 @@ export type SecurityCounterName = | "warehouse_export_runs" | "warehouse_records_redacted" | "warehouse_export_failed" + | "flag_ops_version_conflict" + | "flag_kill_switch_activated" + | "flag_kill_switch_lifted" + | "flag_ops_stale_fallback" + | "flag_kill_switch_drill_passed" + | "lp_quotes_served" + | "lp_quote_stale_rejected" + | "lp_deposit_prepared" + | "lp_withdraw_prepared" + | "lp_triple_trustline_required" + | "lp_pool_cap_breached" + | "lp_pool_self_provision_flagged" + | "lp_pool_self_swap_flagged" + | "lp_pool_volume_excluded" + | "lp_router_address_drift" + | "lp_reserve_stale_on_error" + | "lp_swap_slippage_rejected" + | "deep_link_rejected" + | "deep_link_resolved" + | "compliance_webhook_replay" + | "compliance_unavailable" + | "compliance_denied" + | "compliance_allowed" + | "dep_cadence_audit_passed" + | "dep_cadence_skew_detected" + | "dep_sdk_parity_verified" + | "dep_wasm_bytecode_drift" + | "tax_event_recorded" + | "tax_exported" | "anomaly_checks_run" | "anomaly_volume_breaches" | "anomaly_counter_breaches" diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index 1ac65daf..ad3f1bbb 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -54,12 +54,12 @@ const FILES = { searchQueryLog: "search-query-log.json", costBudgets: "cost-budgets.json", forgeBilling: "forge-billing.json", - legalHolds: "legal-holds.json", - termsConsents: "terms-consents.json", - legalDocuments: "legal-documents.json", leaderboardEntries: "leaderboard-entries.json", gdprErasureLog: "gdpr-erasure-log.json", anomalyBaselines: "anomaly-baselines.json", + featureFlagOps: "feature-flag-ops.json", + referralQuests: "referral-quests.json", + liquidityPoolIntents: "liquidity-pool-intents.json", } as const diff --git a/lib/stellar-wallet-kit.ts b/lib/stellar-wallet-kit.ts index e5665ece..70a90cf4 100644 --- a/lib/stellar-wallet-kit.ts +++ b/lib/stellar-wallet-kit.ts @@ -15,6 +15,7 @@ import { WALLET_CONNECT_ID, } from "@creit.tech/stellar-wallets-kit/modules/wallet-connect" import { albedoImplicitTxAllowed, isAlbedoSelectedInKit } from "@/lib/albedo-intent-client" +import { albedoRetiredSignMessage, isAlbedoRetired } from "@/lib/albedo-sunset" import { getWalletConnectRelayUrl } from "@/lib/walletconnect-resilience" import { XBULL_ID, @@ -242,7 +243,9 @@ export async function signTransaction( if (typeof window !== "undefined" && isXBullSelected()) { try { const rawRes = await StellarWalletsKit.signTransaction(xdr, opts) - return normalizeXBullSignResult(rawRes) + const normalized = normalizeXBullSignResult(rawRes) + if (normalized.error) return { error: { message: normalized.error } } + return { signedTxXdr: normalized.signedTxXdr, signedTransaction: normalized.signedTransaction } } catch (err: unknown) { const mapped = mapXBullError(err) return { error: { message: mapped.message } } diff --git a/lib/viewer-signature.ts b/lib/viewer-signature.ts index 5b56bae4..df6c5200 100644 --- a/lib/viewer-signature.ts +++ b/lib/viewer-signature.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-nocheck /** * Community-signal proof of wallet ownership (SEP-53 Ed25519 message signing). * @@ -154,13 +154,13 @@ export async function signSignalPayload( } -/** +/* * Firma corta derivada del visor (wallet u otro id) — cambia por sesión/dirección; no es criptografía fuerte. - * Restored from f9a08b6: 7cc03e4 removed it while fusion-chamber, phase-artifact-visualizer and - * phase-protected-preview still import it for the visual watermark. + * Kept (7cc03e4) because fusion-chamber, phase-artifact-visualizer and phase-protected-preview + * still import it for the visual watermark. */ -export function viewerSignatureShort(address: string | null | undefined): string { - const s = (address ?? "ANON_VIEWER").trim() +export function viewerSignatureShortWatermark(addressOrWallet: string | null | undefined): string { + const s = (addressOrWallet ?? "ANON_VIEWER").trim() let h = 2166136261 for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i) diff --git a/lib/wash-screening.ts b/lib/wash-screening.ts index 7c9b72ef..10aee003 100644 --- a/lib/wash-screening.ts +++ b/lib/wash-screening.ts @@ -23,7 +23,12 @@ */ import { getDb } from "@/lib/sqlite-db" -import { detectCircularTrades, detectSelfTrading } from "@/lib/wash-trading" +import { + detectCircularTrades, + detectSelfProvisionedPools, + detectSelfSwaps, + detectSelfTrading, +} from "@/lib/wash-trading" import { incSecurityCounter } from "@/lib/security-counters" export type WashScreenResult = { @@ -220,6 +225,42 @@ export function washCompWeight(input: { washFlagged: boolean; repeatPairCount: n return repeats === 0 ? 1 : 0.5 ** repeats } +// ── Spike 574 (phase-182): pool self-provision and self-swap ── +// A classic pool lets one wallet be both liquidity provider and only trader. +// The NFT detectors above cannot see it: there is no second wallet and no +// token id. The rule stays flag-not-block, and every flagged row is excluded +// from `getPoolVolumeExcludingWash`. + +export function screenPoolProvision(input: { + poolId: string + wallet: string + shareBps: number + swapWallets: string[] +}): WashScreenResult { + const result = detectSelfProvisionedPools([ + { + poolId: input.poolId, + wallet: input.wallet, + shareBps: input.shareBps, + swapWallets: input.swapWallets, + }, + ]) + if (result.flaggedPoolIds.length === 0) return { washFlagged: false, washReason: null } + incSecurityCounter("lp_pool_self_provision_flagged") + return { washFlagged: true, washReason: result.details[0] ?? "self_provision" } +} + +export function screenPoolSwap(input: { + poolId: string + wallet: string + counterparty?: string | null +}): WashScreenResult { + const result = detectSelfSwaps([input]) + if (result.flaggedIndexes.length === 0) return { washFlagged: false, washReason: null } + incSecurityCounter("lp_pool_self_swap_flagged") + return { washFlagged: true, washReason: result.details[0] ?? "self_swap" } +} + // ── Spikes 673-675 (flag-gated): per-edition wash context ── // Wash screening stays listing-scoped; edition fields are carried for per-edition aggregates // without changing the flag-not-block contract. diff --git a/lib/wash-trading.ts b/lib/wash-trading.ts index e78a7254..c5ea2b1a 100644 --- a/lib/wash-trading.ts +++ b/lib/wash-trading.ts @@ -281,6 +281,58 @@ export function auditWashTradingWiring(): { ok: boolean; note: string } { } } +// ── Spike 574 (phase-182): self-provision + self-swap pool heuristics ── +// Classic AMM pools introduce a manipulation primitive the existing NFT-trade +// detectors cannot see: a wallet can deposit most of a pool's liquidity and then +// swap against itself to print volume at a price it chose. Both shapes are +// flagged, never blocked (a solo testnet pool is legitimate), and callers must +// exclude flagged rows from volume aggregates. + +export type PoolProvision = { + poolId: string + wallet: string + /** Share of total pool shares in basis points. */ + shareBps: number + /** Wallets observed swapping against this pool in the window. */ + swapWallets?: string[] +} + +export function detectSelfProvisionedPools( + provisions: PoolProvision[], + opts: { concentrationBps?: number } = {}, +): { flaggedPoolIds: string[]; details: string[] } { + const threshold = opts.concentrationBps ?? 9_000 + const flagged = new Set() + const details: string[] = [] + for (const p of provisions) { + if (p.shareBps < threshold) continue + const wallets = new Set((p.swapWallets ?? []).map((w) => w.toUpperCase())) + const onlySelf = wallets.size > 0 && [...wallets].every((w) => w === p.wallet.toUpperCase()) + if (!onlySelf && wallets.size > 0) continue + if (wallets.size === 0) continue + flagged.add(p.poolId) + details.push( + `Self-provisioned pool ${p.poolId.slice(0, 8)}…: ${p.wallet.slice(0, 6)}… holds ${p.shareBps} bps and is the only swapper.`, + ) + } + return { flaggedPoolIds: [...flagged], details } +} + +export type PoolSwap = { poolId: string; wallet: string; counterparty?: string | null } + +export function detectSelfSwaps(swaps: PoolSwap[]): { flaggedIndexes: number[]; details: string[] } { + const flagged: number[] = [] + const details: string[] = [] + swaps.forEach((s, i) => { + if (!s.counterparty) return + if (s.counterparty.trim().toUpperCase() === s.wallet.trim().toUpperCase()) { + flagged.push(i) + details.push(`Self-swap on pool ${s.poolId.slice(0, 8)}… by ${s.wallet.slice(0, 6)}….`) + } + }) + return { flaggedIndexes: flagged, details } +} + // ── Spike 673 (flag-gated): edition-scoped circular-trade hint ── // Existing circular/rapid-flip/self detectors are unchanged; this pure helper lets callers // partition trade windows per edition_number so one edition cannot launder another. diff --git a/lib/xbull-adapter.ts b/lib/xbull-adapter.ts index c10260b1..ee961a66 100644 --- a/lib/xbull-adapter.ts +++ b/lib/xbull-adapter.ts @@ -17,6 +17,13 @@ export function xbullRollbackNote(): string { } export const XBULL_ID = "xbull" + +/** Module-id matcher used by the wallet-kit dispatcher (spike 624). */ +export function isXBullModuleId(id: unknown): boolean { + if (typeof id !== "string") return false + const normalized = id.trim().toLowerCase().replace(/[^a-z]/g, "") + return normalized === "xbull" || normalized === "xbullwallet" +} const LS_SELECTED_MODULE_ID = "@StellarWalletsKit/selectedModuleId" /** @@ -30,6 +37,29 @@ export function isXBullSelected(): boolean { // ── Freighter vs xBull Difference Matrix ───────────────────────────────────── +export const XBULL_FREIGHTER_DIFFERENCE_MATRIX: Array<{ dimension: string; freighter: string; xbull: string }> = [ + { + dimension: "Sign Result Shape", + freighter: "returns { signedTxXdr }", + xbull: "returns xdr/result or a raw string; normalize before submitting", + }, + { + dimension: "Execution Model", + freighter: "browser extension injects window.freighterApi", + xbull: "wallet-kit module opens a popup or the mobile app", + }, + { + dimension: "Error Format", + freighter: "throws Error with a plain message", + xbull: "rejects with a code/message object; map to XBullErrorMapped", + }, + { + dimension: "Trustline Flow", + freighter: "changeTrust signed in-extension", + xbull: "changeTrust signed through the kit module; same XDR, different prompt", + }, +] + export const FREIGHTER_XBULL_DIFFERENCE_MATRIX = { returnShape: { freighter: "{ signedTxXdr: string }", @@ -61,9 +91,11 @@ export const FREIGHTER_XBULL_DIFFERENCE_MATRIX = { export function normalizeXBullSignResult(result: unknown): { signedTxXdr: string signedTransaction: string + error?: string } { if (!result) { - throw new Error("xBull returned empty signature result.") + recordSecurityCounter("xbull_error_mapped") + return { signedTxXdr: "", signedTransaction: "", error: "xBull returned empty signature result." } } // String result (raw XDR) @@ -88,13 +120,31 @@ export function normalizeXBullSignResult(result: unknown): { } } - throw new Error("xBull signature result could not be normalized into transaction XDR.") + recordSecurityCounter("xbull_error_mapped") + return { + signedTxXdr: "", + signedTransaction: "", + error: "xBull signature result could not be normalized into transaction XDR.", + } +} + +// Closed-label counter wrappers so call sites cannot invent label names. +export function recordXBullSignRequested(): void { + recordSecurityCounter("xbull_sign_requested") +} + +export function recordXBullSignSuccess(): void { + recordSecurityCounter("xbull_sign_success") +} + +export function recordXBullTrustlineSigned(): void { + recordSecurityCounter("xbull_trustline_signed") } // ── Error Mapper ───────────────────────────────────────────────────────────── export type XBullErrorMapped = { - code: "REJECTED" | "NOT_INSTALLED" | "LOCKED" | "INVALID_NETWORK" | "POPUP_CLOSED" | "UNKNOWN" + code: "USER_REJECTED" | "NOT_INSTALLED" | "LOCKED" | "NETWORK_MISMATCH" | "POPUP_CLOSED" | "SIGN_FAILED" message: string retryable: boolean userAction: string @@ -110,7 +160,7 @@ export function mapXBullError(err: unknown): XBullErrorMapped { if (/user_rejected|cancelled|cancelado|rejected|denied/i.test(lower)) { return { - code: "REJECTED", + code: "USER_REJECTED", message: "xBull: Transacción cancelada por el usuario. / Transaction rejected by user in xBull.", retryable: true, userAction: "Reintentá cuando estés listo para firmar en xBull.", @@ -118,6 +168,7 @@ export function mapXBullError(err: unknown): XBullErrorMapped { } if (/extension not installed|not found|no provider/i.test(lower)) { + recordSecurityCounter("xbull_error_mapped") return { code: "NOT_INSTALLED", message: "xBull: Extensión no detectada en el navegador. / xBull extension not detected.", @@ -127,6 +178,7 @@ export function mapXBullError(err: unknown): XBullErrorMapped { } if (/password rejected|locked|bloquead/i.test(lower)) { + recordSecurityCounter("xbull_error_mapped") return { code: "LOCKED", message: "xBull: La wallet está bloqueada o contraseña incorrecta. / xBull wallet is locked.", @@ -135,9 +187,10 @@ export function mapXBullError(err: unknown): XBullErrorMapped { } } - if (/invalid network|network mismatch|wrong network/i.test(lower)) { + if (/invalid network|network (miss?match|not match)|wrong network/i.test(lower)) { + recordSecurityCounter("xbull_error_mapped") return { - code: "INVALID_NETWORK", + code: "NETWORK_MISMATCH", message: "xBull: Red incorrecta configurada en la wallet. / xBull network mismatch.", retryable: true, userAction: "Abrí xBull y cambiá a la red Stellar Testnet.", @@ -145,6 +198,7 @@ export function mapXBullError(err: unknown): XBullErrorMapped { } if (/closed|blur|dismissed/i.test(lower)) { + recordSecurityCounter("xbull_error_mapped") return { code: "POPUP_CLOSED", message: "xBull: La ventana de firma se cerró antes de completar. / xBull popup closed.", @@ -153,8 +207,9 @@ export function mapXBullError(err: unknown): XBullErrorMapped { } } + recordSecurityCounter("xbull_error_mapped") return { - code: "UNKNOWN", + code: "SIGN_FAILED", message: `xBull error: ${raw}`, retryable: true, userAction: "Revisá la extensión xBull y volvé a intentar.", diff --git a/scripts/distributor-trust-and-payment.ts b/scripts/distributor-trust-and-payment.ts index 7911705f..1498d593 100644 --- a/scripts/distributor-trust-and-payment.ts +++ b/scripts/distributor-trust-and-payment.ts @@ -25,7 +25,10 @@ import { Asset, BASE_FEE, Horizon, + getLiquidityPoolId, Keypair, + LiquidityPoolAsset, + LiquidityPoolFeeV18, Networks, Operation, StrKey, @@ -165,6 +168,49 @@ async function main() { console.log(` ${base}/transactions/${trustRes.hash}\n`) } + // Spike 574 (phase-182): a PHASELQ/XLM pool needs a *second* trustline on the + // POOL SHARE asset. Without it the distributor can hold PHASELQ and XLM but + // can never receive LP shares, so the seed deposit fails after signing. + // Opt-in: LP_POOL_SEED_ENABLED=1. + if (process.env.LP_POOL_SEED_ENABLED?.trim() === "1") { + console.log("Pool seed enabled — verificando trustline de pool shares…") + try { + const accountDist = await server.loadAccount(distPub) + const poolAsset = new LiquidityPoolAsset(asset, Asset.native(), LiquidityPoolFeeV18) + const poolId = Buffer.from( + getLiquidityPoolId("constant_product", { + assetA: asset, + assetB: Asset.native(), + fee: LiquidityPoolFeeV18, + }), + ).toString("hex") + const hasShareLine = accountDist.balances.some( + (b: any) => b.asset_type === "liquidity_pool_shares" && b.liquidity_pool_id === poolId, + ) + if (hasShareLine) { + console.log(` Pool-share trustline ya activa (${poolId.slice(0, 12)}…).\n`) + } else { + const txPoolTrust = new TransactionBuilder(accountDist, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.changeTrust({ asset: poolAsset })) + .setTimeout(180) + .build() + txPoolTrust.sign(distributorKp) + const poolTrustRes = await server.submitTransaction(txPoolTrust) + console.log(` OK — changeTrust pool shares. Hash: ${poolTrustRes.hash}`) + console.log(` pool_id: ${poolId}`) + console.log( + ` Siguiente: depositá LP_POOL_SEED_AMOUNT con POST /api/liquidity-pools (action=deposit) y registrá el seed con setPoolSeedReserve.\n`, + ) + } + } catch (e) { + console.error(" No se pudo preparar la trustline del pool:", e instanceof Error ? e.message : e) + process.exit(1) + } + } + console.log("Enviando payment issuer → distribuidor (firma: issuer)…") const accountIssuer = await server.loadAccount(issuerPub) const txPay = new TransactionBuilder(accountIssuer, { diff --git a/scripts/issuer-pay-distributor.ts b/scripts/issuer-pay-distributor.ts index d60b37e4..1eac0076 100644 --- a/scripts/issuer-pay-distributor.ts +++ b/scripts/issuer-pay-distributor.ts @@ -169,6 +169,18 @@ async function main() { console.log("Hash:", result.hash) console.log(`${base}/transactions/${result.hash}`) console.log(`${base}/accounts/${dest}`) + + // Spike 574 (phase-182): part of the distributor balance may be pool seed + // capital. It is visible on Horizon but NOT spendable by faucet/refill; the + // ledger subtracts `pool_seed_stroops` in availableBalance(). Print the split + // so an operator never tops up against a balance that is already committed. + const seedStroops = BigInt(process.env.LP_POOL_SEED_STROOPS?.trim() || "0") + if (seedStroops > 0n) { + console.log("") + console.log("⚠️ LP pool seed is configured (LP_POOL_SEED_STROOPS=%s).", seedStroops.toString()) + console.log(" That portion is not spendable: call setPoolSeedReserve(distributor, seed) so") + console.log(" availableBalance() and auto-refill stop treating it as free capital.") + } } main().catch((e) => {