diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 8e0ac7cf..57bf8348 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -152,7 +152,28 @@ function errorStream(code: 'agents_offline' | 'chain_unsupported', message: stri } export async function POST(request: Request): Promise { - const limited = await enforceRateLimit(request, 'compete') + // Peeked from a clone, before the real parse and before rate-limiting: a + // race is costed per caller, and a caller is their wallet when one is + // connected, not only the IP they may share with everyone else behind the + // same proxy or campus network. A clone because the body stream can be + // read only once, and the real parse below still needs it. Anything that + // fails to read as a string here is silently absent — the malformed body + // is handled once, in the ordinary parse below, not twice. + const account = await request + .clone() + .json() + .then((b: unknown) => + typeof b === 'object' && + b !== null && + 'account' in b && + typeof b.account === 'string' && + b.account.length <= 64 + ? b.account + : undefined + ) + .catch(() => undefined) + + const limited = await enforceRateLimit(request, 'compete', account) if (limited !== undefined) return limited let text: string @@ -373,6 +394,9 @@ export async function POST(request: Request): Promise { latencyMs: Date.now() - began, routeId: proposal.routeId, executionMode: proposal.executionMode, + // Passed for every agent; raceRecordFrom is the one place that + // keeps it only for the row that actually won. + reasoning: proposal.reasoning, }) send({ diff --git a/apps/dapp/app/api/agents/health/route.ts b/apps/dapp/app/api/agents/health/route.ts new file mode 100644 index 00000000..60a5b4bc --- /dev/null +++ b/apps/dapp/app/api/agents/health/route.ts @@ -0,0 +1,85 @@ +import { activeNetwork } from '@intent/config' +import { NextResponse } from 'next/server' + +import { readAgentHealth, type AgentHealthReport } from '../../../../lib/server/agent-health' +import { alertRecipient, getAlertsRepo } from '../../../../lib/server/alerts' +import { getEmailSender } from '../../../../lib/server/email' +import { getPool, withTimeout } from '../../../../lib/server/db' + +/** + * The scheduled check of whether the agent roster is actually answering. + * + * GET, authenticated with `CRON_SECRET`, because that is what Vercel Cron + * actually sends and the method it actually calls with — a GET request + * carrying `Authorization: Bearer ` — rather than the POST and + * custom header `standing/tick` uses, which was never wired to a scheduler + * in the first place (no `vercel.json` existed before this). Safe to call + * repeatedly: an alert goes out at most once per severity per day + * (`alerts_sent`, the same table the sponsor-balance alert uses). + * + * This replaces someone running curl against production and reading Vercel + * logs by hand to notice a dead or rate-limited model. It reads the races + * the app already logs rather than running a synthetic race of its own; see + * `agent-health.ts` for why. + */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const MIN_SECRET_LENGTH = 16 +const ALERT_KIND = 'agent_health' + +function authorised(request: Request, expected: string): boolean { + return request.headers.get('authorization') === `Bearer ${expected}` +} + +function alertText(report: AgentHealthReport): { subject: string; text: string } { + const pct = report.answerRate !== undefined ? `${(report.answerRate * 100).toFixed(0)}%` : 'n/a' + return { + subject: `[Intent] Agent health: ${report.severity} on ${report.network}`, + text: [ + `Agent answer rate over the last ${report.window.hours}h: ${pct} ` + + `(${report.answeredTotal}/${report.askedTotal} asks, ${report.window.races} races).`, + ``, + ...report.reasons.map((r) => `- ${r}`), + ``, + `Per-agent, this window:`, + ...report.coreAgents.map((a) => ` ${a.agent}: ${a.answered}/${a.races}`), + ].join('\n'), + } +} + +export async function GET(request: Request): Promise { + const expected = process.env['CRON_SECRET'] + if (expected === undefined || expected.length < MIN_SECRET_LENGTH) { + return NextResponse.json( + { + error: `CRON_SECRET is not set (or is shorter than ${MIN_SECRET_LENGTH} characters). Refusing to run an open check.`, + }, + { status: 503 } + ) + } + if (!authorised(request, expected)) { + return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + } + + const pool = getPool() + const report = await readAgentHealth(async (sql, params) => { + const result = await withTimeout(pool.query(sql, params), 8_000) + return { rows: result.rows as Record[] } + }, activeNetwork()) + + if (report.severity === 'warning' || report.severity === 'critical') { + const to = alertRecipient() + if (to !== undefined) { + const alerts = await getAlertsRepo() + const day = new Date().toISOString().slice(0, 10) + const kind = `${ALERT_KIND}:${report.severity}:${report.network}` + if (!(await alerts.wasSent(kind, day))) { + await getEmailSender().sendAlert(to, alertText(report)) + await alerts.markSent(kind, day) + } + } + } + + return NextResponse.json(report) +} diff --git a/apps/dapp/app/api/agents/history/route.ts b/apps/dapp/app/api/agents/history/route.ts new file mode 100644 index 00000000..e4aaf623 --- /dev/null +++ b/apps/dapp/app/api/agents/history/route.ts @@ -0,0 +1,62 @@ +import { activeNetwork } from '@intent/config' +import { StrKey } from '@stellar/stellar-sdk' +import { NextResponse } from 'next/server' + +import { databaseConfigured } from '../../../../lib/server/db' +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { getAnalyticsRepo } from '../../../../lib/server/analytics' +import { reportError } from '../../../../lib/server/report' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const DEFAULT_PAGE_SIZE = 20 +const MAX_PAGE_SIZE = 100 + +/** + * A wallet's own agent races, each with the winning agent's reasoning and + * the transaction this wallet actually submitted for it, when there was one. + * + * Why this is a question anyone can ask about any address, rather than one + * gated behind a login: the naira cash-out history is the closer precedent + * for app-database-backed (as opposed to on-ledger) wallet data, and it + * does gate — but its gate exists because a cash-out carries a bank name, + * an account number, and an email, none of which this route ever returns. + * What this route exposes is why an agent proposed what it proposed, + * exactly as it was shown live in the competition panel to whoever was + * watching the race happen. A Stellar address is already public, and this + * matches the trust model `swap/history` and the ledger itself already use + * for everything else this wallet did. + */ +export async function GET(request: Request): Promise { + const limited = await enforceRateLimit(request, 'resolve') + if (limited !== undefined) return limited + + const url = new URL(request.url) + const account = url.searchParams.get('account') + if (account === null || !StrKey.isValidEd25519PublicKey(account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + const pageParam = Number(url.searchParams.get('page') ?? '1') + const page = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1 + const pageSize = Math.max(1, Math.min(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE)) + + if (!databaseConfigured()) { + return NextResponse.json({ entries: [], hasMore: false }) + } + + try { + const repo = await getAnalyticsRepo() + const result = await repo.historyForAccount({ + network: activeNetwork(), + account, + page, + pageSize, + }) + return NextResponse.json(result) + } catch (e) { + reportError('agents/history', e) + return NextResponse.json({ entries: [], hasMore: false }) + } +} diff --git a/apps/dapp/app/api/swap/build/route.ts b/apps/dapp/app/api/swap/build/route.ts index b3fdcff9..01db7d43 100644 --- a/apps/dapp/app/api/swap/build/route.ts +++ b/apps/dapp/app/api/swap/build/route.ts @@ -11,6 +11,8 @@ import { createHorizonQuoter } from '../../../../lib/swap/sources/horizon-quoter import { createSoroswapQuoter } from '../../../../lib/swap/sources/soroswap-quoter' import { createAquariusQuoter } from '../../../../lib/swap/sources/aquarius-quoter' import { buildAquariusSwap } from '../../../../lib/swap/build-aquarius' +import { createPhoenixQuoter } from '../../../../lib/swap/sources/phoenix-quoter' +import { buildPhoenixSwap } from '../../../../lib/swap/build-phoenix' import { assertAggregatorSwap, buildAggregatorSwap } from '../../../../lib/swap/build-aggregator' import { buildSorobanSwap, prepareSorobanSwap } from '../../../../lib/swap/build-soroban' import { createSoroswapApi } from '../../../../lib/swap/soroswap-api' @@ -109,6 +111,9 @@ export async function POST(request: Request): Promise { if (venue === 'aquarius') { return await buildViaAquarius(body.account, submitted, chosenBps ?? DEFAULT_SLIPPAGE_BPS) } + if (venue === 'phoenix') { + return await buildViaPhoenix(body.account, submitted, chosenBps ?? DEFAULT_SLIPPAGE_BPS) + } if (venue === 'aggregator') { return await buildViaAggregator(body.account, submitted, chosenBps) } @@ -463,3 +468,80 @@ async function buildViaAquarius( quote: fresh, }) } + +async function buildViaPhoenix( + account: string, + submitted: SwapQuote, + slippageBps: number +): Promise { + if (submitted.poolAddress === undefined) { + return NextResponse.json( + { error: 'a Phoenix route must name the pool it was quoted from' }, + { status: 400 } + ) + } + + const all = await createPhoenixQuoter().quoteAll?.({ + kind: 'strict_send', + from: submitted.from, + to: submitted.to, + sendAmount: submitted.sendAmount, + }) + + if (all === undefined || !all.ok) { + return NextResponse.json( + { error: 'no_route', reason: all?.ok === false ? all.failure.reason : 'no_route' }, + { status: 200 } + ) + } + + const fresh = all.quotes.find((q) => q.poolAddress === submitted.poolAddress) + if (fresh === undefined) { + // The pool existed when quoted and does not now — Phoenix's factory + // can retire a pool between quote and build. Said plainly rather than + // silently substituting whichever pool remains. + return NextResponse.json( + { error: 'no_route', reason: 'the quoted pool is no longer available' }, + { status: 200 } + ) + } + + const capped = await overCap(fresh.from.code, fresh.sendAmount) + if (capped !== undefined) return capped + + const minReceive = applySlippage(fresh.destAmount, slippageBps) + + let built + try { + built = await buildPhoenixSwap({ + account, + from: fresh.from, + to: fresh.to, + sendAmount: fresh.sendAmount, + minReceive, + poolAddress: submitted.poolAddress, + }) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'could not build the transaction' }, + { status: 400 } + ) + } + + // Venue-agnostic: it simulates and assembles whatever envelope it is given. + const prepared = await prepareSorobanSwap(built.xdr) + if (!prepared.ok) { + return NextResponse.json( + { error: 'simulation_failed', reason: prepared.reason }, + { status: 409 } + ) + } + + return NextResponse.json({ + xdr: prepared.xdr, + destMin: minReceive, + sendAmount: built.sendAmount, + slippageBps, + quote: fresh, + }) +} diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index d7991fe6..aae08177 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -113,6 +113,12 @@ import { ComposerInput } from './composer-input' import { PriceTicker } from './price-ticker' import { agentBubble, systemPill, userBubble } from './bubble' import { AgentAvatar } from '../agents/agent-avatar' +import { + clearPendingCashOut, + pendingCashOut, + setPendingCashOut, + type PendingCashOut, +} from '../../lib/pending-cashout' /** * The price of the only asset an anchor withdraws. @@ -180,6 +186,11 @@ export function IntentChat(): JSX.Element { // A cash-out of anything but USDC: the agents race on the swap into USDC // first, and the payout form opens only once that swap has settled. const [cashOutAfterSwap, setCashOutAfterSwap] = useState(false) + // A swap that settled meaning to be followed by a cash-out, where the tab + // closed (or the user navigated away) before that second step ever + // started. Read once per wallet, not kept in sync with cashOutFrom below: + // this exists for the gap *before* that state exists, not alongside it. + const [strandedCashOut, setStrandedCashOut] = useState(undefined) // The settled swap a cash-out continues from, kept apart from the swap card // so its Done button, or any reset of the card, cannot take the form away. const [cashOutFrom, setCashOutFrom] = useState<{ hash: string; receive: string } | null>(null) @@ -272,6 +283,14 @@ export function IntentChat(): JSX.Element { const { slug } = useChain() const { address, isConnected, balance: nativeBalance, balanceSymbol } = useWallet() + + // Checked once the wallet that owns it is known — historyOwner, which + // pendingCashOut reads, is set from this same address elsewhere — so a + // reminder left by a different wallet in this browser is never shown + // under the wrong one. + useEffect(() => { + setStrandedCashOut(isConnected ? pendingCashOut() : undefined) + }, [isConnected, address]) // Past intents belong to a wallet. With none connected there is nothing // to attribute them to, so the overlay closes and its button goes away. useEffect(() => { @@ -361,10 +380,12 @@ export function IntentChat(): JSX.Element { // toggle used to select a mock competition here, and a stale bundle once // ran it silently while the server's agents worked — every check passed and // the screen showed canned proposals nobody could execute. - const live = useCompetition(restored === null ? parsed : null, slug, [ - ...raceContext(thread, notes), - ...brief, - ]) + const live = useCompetition( + restored === null ? parsed : null, + slug, + [...raceContext(thread, notes), ...brief], + address + ) const liveCompetition = live // A restored turn is already decided: every agent revealed, a winner picked. @@ -638,6 +659,10 @@ export function IntentChat(): JSX.Element { useEffect(() => { if (!swapSettled || !cashOutAfterSwap || address === undefined) return const settled = { hash: swap.hash ?? '', receive: swap.receiveDisplay ?? '' } + // Marked the instant the swap is known to owe a cash-out, not after the + // form opens: if the tab closes during the balance refetch below, the + // swap already settled and the reminder must already exist to find it. + setPendingCashOut(settled.hash, settled.receive) // The form opens once the balance has been read again, so it starts from // the USDC that arrived rather than a figure from before the swap. void queryClient @@ -764,6 +789,32 @@ export function IntentChat(): JSX.Element { setParsed(parseIntent(`Swap ${amount} ${symbol} to USDC`, swap.usdPrices)) } + /** + * Opens the cash-out form for a swap that already settled, left behind by + * a closed tab. The swap itself needs nothing re-run — it is already in + * history — so this only starts a turn to hold the payout and shows the + * form, the same state `cashOutAfterSwap`'s settle effect would have + * reached if the tab had stayed open. + */ + function resumeStrandedCashOut(stranded: PendingCashOut): void { + const newTurnId = crypto.randomUUID() + const text = `Cash out ${stranded.receiveDisplay} USDC to naira` + setTurnId(newTurnId) + setMessage(text) + setCashOutFrom({ hash: stranded.swapHash, receive: stranded.receiveDisplay }) + setStrandedCashOut(undefined) + saveTurn({ + id: newTurnId, + chain: slug, + text, + createdAt: new Date().toISOString(), + proposals: {}, + winner: null, + txHash: stranded.swapHash, + }) + setTurns(loadTurns(slug)) + } + function startIntent( text: string, options: { @@ -1934,6 +1985,36 @@ export function IntentChat(): JSX.Element { was as invisible as a success. */} {!message ? (
+ {/* A swap from an earlier session settled meaning to be cashed + out, and the tab closed before that second step started. + The swap itself is already safe in history; this is only + the reminder that one more step was owed. */} + {strandedCashOut !== undefined ? ( +
+

+ A swap settled with {strandedCashOut.receiveDisplay} USDC waiting to be cashed + out to naira. +

+
+ + + View the swap + +
+
+ ) : null} + + ) : null} {sequence.kind === 'swap-then-naira' && @@ -2383,6 +2467,10 @@ export function IntentChat(): JSX.Element { : {})} {...(usdcHeld !== undefined ? { balanceUsdc: usdcHeld } : {})} onOrder={(order) => { + // An order now exists for this swap's USDC — the thing + // the reminder below exists to chase is done, whatever + // happens to the order from here. + clearPendingCashOut() if (turnId === null) return updateTurn( turnId, diff --git a/apps/dapp/components/intents/naira-payout-card.tsx b/apps/dapp/components/intents/naira-payout-card.tsx index 2df7ff10..906601de 100644 --- a/apps/dapp/components/intents/naira-payout-card.tsx +++ b/apps/dapp/components/intents/naira-payout-card.tsx @@ -129,10 +129,18 @@ function PayoutProgress({ status, order, bank, + onRetry, }: { status: NairaStatus order: PayoutSummary bank?: Bank | undefined + /** + * Absent for `NairaPayoutCard`'s own tracking view, which already has + * `edit()` to go back; present for `NairaOrderTracker`'s standalone view, + * which previously had none — a failed or expired payout read as a dead + * end with no way to try again, for the USDC that already arrived. + */ + onRetry?: () => void }): JSX.Element { const done = status === 'paid' const bad = status === 'failed' || status === 'expired' @@ -177,6 +185,15 @@ function PayoutProgress({ ) : null} {bad ?

{STATUS_COPY[status]}

: null} + {bad && onRetry !== undefined ? ( + + ) : null}
b.name === order?.bankName) if (phase === 'tracking' && order !== undefined) { - return + return } if ( @@ -725,7 +742,14 @@ export function NairaPayoutCard({ * The card above follows its own orders; a two-leg payout is made inside the * sequence, so this reads the same record by id until it reaches an end. */ -export function NairaOrderTracker({ orderId }: { orderId: string }): JSX.Element { +export function NairaOrderTracker({ + orderId, + onRetry, +}: { + orderId: string + /** Called on a failed or expired payout; absent, no retry is offered. */ + onRetry?: () => void +}): JSX.Element { // Shares the form's cache, so the logo costs no extra request. const banks = useQuery({ queryKey: ['naira-banks'], @@ -773,6 +797,7 @@ export function NairaOrderTracker({ orderId }: { orderId: string }): JSX.Element status={data.status} order={data} bank={banks.data?.find((b) => b.name === data.bankName)} + {...(onRetry !== undefined ? { onRetry } : {})} /> ) } diff --git a/apps/dapp/components/intents/swap-confirm.tsx b/apps/dapp/components/intents/swap-confirm.tsx index 3c1ce05b..83c2c1d2 100644 --- a/apps/dapp/components/intents/swap-confirm.tsx +++ b/apps/dapp/components/intents/swap-confirm.tsx @@ -11,9 +11,10 @@ import { Loader2, TriangleAlert, } from 'lucide-react' -import { useState, type ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import type { SwapPhase } from '../../hooks/use-swap' +import { confirmPauseUsd, needsConfirmPause } from '../../lib/swap/confirm-pause' import type { LedgerPreview as LedgerPreviewData } from '../../lib/swap/preview' import { sourceDisplayName, type SwapQuote } from '../../lib/swap/quote' import { needsTrustCaution, trustSummary, verificationOf } from '../../lib/swap/asset-registry' @@ -30,7 +31,59 @@ import { LedgerPreview } from './ledger-preview' * * The wallet is the real confirmation step, so this deliberately does not try * to be one. It shows the numbers, then hands over. + * + * One exception: a trade whose USD value crosses a threshold holds the + * primary button disabled for a couple of seconds, with a visible countdown + * and a one-line reason. Not a second signature gate — the wallet still owns + * that — but a forced pause on the one screen most likely to be misread in a + * hurry, for the one class of mistake (a fat-fingered amount, a plan that + * looks fine at a glance but is worth acting slowly on) a wallet's own prompt + * does nothing to catch. + */ + +/** + * The threshold itself, inlined at build time like every other + * `NEXT_PUBLIC_` value in this app. Not read from `trade-cap.ts`'s + * `MAINNET_MAX_TRADE_USD`: that is a server-side hard cap enforced at submit + * time, checked against the live price table rather than the client's + * `usdPrices` snapshot, and it exists to refuse a trade outright. This is a + * client-side pause that exists to slow a human down, and the two moving + * independently is deliberate — raising the hard cap to let bigger trades + * through should not silently also raise how much friction a human sees + * first, and vice versa. + */ +const CONFIRM_PAUSE_THRESHOLD_USD = Number( + process.env.NEXT_PUBLIC_SWAP_CONFIRM_THRESHOLD_USD ?? '500' +) + +/** Seconds the primary button stays disabled once the threshold is crossed. */ +const CONFIRM_PAUSE_SECONDS = 2 + +/** + * Counts down from `CONFIRM_PAUSE_SECONDS` once per distinct `(active, key)` + * pair; resets to 0 — no pause — whenever `active` is false. `key` identifies + * the specific amount being paused on, so a quote that refreshes to the same + * numbers keeps whatever the countdown already reached, but an edited amount + * — still over threshold, now a different number — gets a fresh pause rather + * than inheriting a countdown that was timing a different trade. */ +function useConfirmPause(active: boolean, key: string): number { + const [secondsLeft, setSecondsLeft] = useState(active ? CONFIRM_PAUSE_SECONDS : 0) + + useEffect(() => { + if (!active) { + setSecondsLeft(0) + return + } + setSecondsLeft(CONFIRM_PAUSE_SECONDS) + const interval = setInterval(() => { + setSecondsLeft((s) => Math.max(0, s - 1)) + }, 1000) + return () => clearInterval(interval) + }, [active, key]) + + return secondsLeft +} /** The USD value of an amount, or undefined when the price is unknown. */ function sendUsdRaw( @@ -230,6 +283,19 @@ export function SwapConfirm({ onConfirm: () => void onReset: () => void }): JSX.Element | null { + // Computed, and the hook called, before any early return: a conditional + // hook call is a React error, and this one's inputs are all props, so + // nothing here depends on the branching below. + const pauseUsd = confirmPauseUsd( + sendUsdRaw(sendDisplay, quote?.from.code, usdPrices), + sendUsdRaw(receiveDisplay, quote?.to.code, usdPrices) + ) + const pauseActive = phase === 'review' && needsConfirmPause(pauseUsd, CONFIRM_PAUSE_THRESHOLD_USD) + const pauseSecondsLeft = useConfirmPause( + pauseActive, + `${sendDisplay ?? ''}:${receiveDisplay ?? ''}` + ) + if (phase === 'idle') return null if (phase === 'quoting') { @@ -424,14 +490,31 @@ export function SwapConfirm({
) : null} + {/* Not a second signature gate — the wallet prompt right after this is + still the real one — but a forced pause before it, on the one + screen most likely to be misread in a hurry on a trade this size. */} + {pauseActive && pauseSecondsLeft > 0 ? ( +

+ This trade is worth about ${pauseUsd?.toFixed(0)}. Take a moment to check the amounts + above before signing. +

+ ) : null} +
- {!busy ? (