Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/dapp/app/api/agents/compete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,28 @@ function errorStream(code: 'agents_offline' | 'chain_unsupported', message: stri
}

export async function POST(request: Request): Promise<Response> {
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
Expand Down Expand Up @@ -373,6 +394,9 @@ export async function POST(request: Request): Promise<Response> {
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({
Expand Down
85 changes: 85 additions & 0 deletions apps/dapp/app/api/agents/health/route.ts
Original file line number Diff line number Diff line change
@@ -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 <CRON_SECRET>` — 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<NextResponse> {
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<string, unknown>[] }
}, 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)
}
62 changes: 62 additions & 0 deletions apps/dapp/app/api/agents/history/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 })
}
}
82 changes: 82 additions & 0 deletions apps/dapp/app/api/swap/build/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -109,6 +111,9 @@ export async function POST(request: Request): Promise<NextResponse> {
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)
}
Expand Down Expand Up @@ -463,3 +468,80 @@ async function buildViaAquarius(
quote: fresh,
})
}

async function buildViaPhoenix(
account: string,
submitted: SwapQuote,
slippageBps: number
): Promise<NextResponse> {
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,
})
}
Loading
Loading