From 5178f54ddad69750cc23d87ffa69af937e01fcc9 Mon Sep 17 00:00:00 2001 From: Depo-dev <24318346+Depo-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:35:32 +0100 Subject: [PATCH 1/7] feat(dapp): retune compete's rate limits and key it by wallet too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten races a minute was a dev-testing number: every race now costs several paid model calls (DeepSeek, Claude, up to two OpenRouter seats), so it is lowered to four. A new 150/day cap, checked independently, bounds a caller who never trips the per-minute window but sustains it for hours — roughly $2 of worst-case Claude spend per key per day. compete was also IP-only: every other route is too, but compete is the one where a race costs real money per request, so it is the one that needed an account key most. The client now sends the connected wallet's address when there is one, peeked from a cloned request so the ordinary body parse is untouched, and the limiter checks it as well as the IP. --- apps/dapp/app/api/agents/compete/route.ts | 23 ++++++- apps/dapp/components/intents/intent-chat.tsx | 10 +-- apps/dapp/hooks/use-competition.ts | 18 +++++- .../lib/__tests__/compete-route-log.test.ts | 25 +++++++- apps/dapp/lib/__tests__/rate-limit.test.ts | 50 +++++++++++++++ apps/dapp/lib/server/rate-limit.ts | 62 ++++++++++++++++++- 6 files changed, 179 insertions(+), 9 deletions(-) diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 8e0ac7cf..c4147ef7 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 diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index d7991fe6..64f55f14 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -361,10 +361,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. diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts index fab46290..d1192678 100644 --- a/apps/dapp/hooks/use-competition.ts +++ b/apps/dapp/hooks/use-competition.ts @@ -54,13 +54,26 @@ export function useCompetition( * user made while agents were working. Sent to the server for every agent * alike. A change restarts the race, which is what "re-run with this" means. */ - context: string[] = [] + context: string[] = [], + /** + * The connected wallet, when there is one. Lets the server rate-limit a + * caller by account as well as by IP — a race costs real provider money, + * and IP alone both over-limits a shared address (NAT, a campus network) + * and under-limits a rotated one. Omitted entirely when absent rather than + * sent as `undefined`, so a disconnected caller is counted by IP only, the + * same as before this existed. + */ + account?: string ): CompetitionWithRoute { // The effect reads the latest context through a ref and restarts on its // content, not on the array's identity, which is new every render. const contextRef = useRef(context) contextRef.current = context const contextKey = context.join('\u0001') + // Read at fetch time, not a dependency: connecting or switching wallets + // mid-race should not restart a race that is already running. + const accountRef = useRef(account) + accountRef.current = account const [proposals, setProposals] = useState>({}) const [revealed, setRevealed] = useState>({}) const [phase, setPhase] = useState('idle') @@ -197,6 +210,9 @@ export function useCompetition( ...(parsed?.reading !== undefined ? { reading: parsed.reading } : {}), chain, ...(contextRef.current.length > 0 ? { context: contextRef.current } : {}), + ...(accountRef.current !== undefined && accountRef.current !== '' + ? { account: accountRef.current } + : {}), }), signal: abort.signal, }) diff --git a/apps/dapp/lib/__tests__/compete-route-log.test.ts b/apps/dapp/lib/__tests__/compete-route-log.test.ts index 528e7b3a..ea12731c 100644 --- a/apps/dapp/lib/__tests__/compete-route-log.test.ts +++ b/apps/dapp/lib/__tests__/compete-route-log.test.ts @@ -21,7 +21,8 @@ vi.mock('../server/analytics', () => ({ state.races.push(input) }, })) -vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +const enforceRateLimit = vi.fn(async () => undefined) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit })) vi.mock('../agents/registry', () => ({ getRoster: () => state.roster })) vi.mock('../agents/market-context', () => ({ buildMarketContextAsync: async () => ({ prices: { XLM: 0.1, USDC: 1 }, routes: [] }), @@ -147,4 +148,26 @@ describe('POST /api/agents/compete', () => { await (await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar' }))).text() expect(state.races).toEqual([]) }) + + it('reads the account from the body and rate-limits by it before validating the rest', async () => { + enforceRateLimit.mockClear() + const { POST } = await import('../../app/api/agents/compete/route') + await ( + await POST( + post({ text: 'swap 10 XLM to USDC', chain: 'stellar', account: 'G'.padEnd(56, 'A') }) + ) + ).text() + + expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', 'G'.padEnd(56, 'A')) + }) + + it('rate-limits a request with a malformed account by IP only', async () => { + enforceRateLimit.mockClear() + const { POST } = await import('../../app/api/agents/compete/route') + await ( + await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar', account: 'x'.repeat(65) })) + ).text() + + expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', undefined) + }) }) diff --git a/apps/dapp/lib/__tests__/rate-limit.test.ts b/apps/dapp/lib/__tests__/rate-limit.test.ts index f11d4303..7e0d026c 100644 --- a/apps/dapp/lib/__tests__/rate-limit.test.ts +++ b/apps/dapp/lib/__tests__/rate-limit.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + RATE_LIMIT_DAILY, RATE_LIMIT_DEFAULTS, checkRateLimit, createRateLimiter, + dailyLimitFor, enforceRateLimit, limitFor, type RateLimiter, @@ -119,6 +121,28 @@ describe('limitFor', () => { }) }) +describe('dailyLimitFor', () => { + it('has a daily cap only for compete', () => { + expect(dailyLimitFor('compete', {})).toEqual(RATE_LIMIT_DAILY.compete) + for (const family of ['build', 'submit', 'resolve', 'standing', 'auth'] as const) { + expect(dailyLimitFor(family, {})).toBeUndefined() + } + }) + + it('reads RATE_LIMIT__DAILY as count/seconds', () => { + expect(dailyLimitFor('compete', { RATE_LIMIT_COMPETE_DAILY: '50/3600' })).toEqual({ + limit: 50, + windowSeconds: 3600, + }) + }) + + it('keeps the default when the override cannot be read', () => { + expect(dailyLimitFor('compete', { RATE_LIMIT_COMPETE_DAILY: 'lots' })).toEqual( + RATE_LIMIT_DAILY.compete + ) + }) +}) + describe('checkRateLimit', () => { it('decides through the limiter it is given', async () => { const input = { key: 'k', limit: 1, windowSeconds: 60 } @@ -283,6 +307,32 @@ describe('enforceRateLimit', () => { ]) }) + it('enforces a daily cap independent of the per-minute window', async () => { + const env = { RATE_LIMIT_COMPETE: '100/60', RATE_LIMIT_COMPETE_DAILY: '2/86400' } + const req = (): Request => post({ 'x-forwarded-for': '1.2.3.4' }) + // Well under the per-minute limit (100), but the daily cap is 2. + expect(await enforceRateLimit(req(), 'compete', undefined, deps(env))).toBeUndefined() + expect(await enforceRateLimit(req(), 'compete', undefined, deps(env))).toBeUndefined() + + const res = await enforceRateLimit(req(), 'compete', undefined, deps(env)) + expect(res?.status).toBe(429) + }) + + it('keys the daily cap separately from the per-minute window', async () => { + const env = { RATE_LIMIT_COMPETE: '1/60', RATE_LIMIT_COMPETE_DAILY: '50/86400' } + await enforceRateLimit(post({ 'x-forwarded-for': '1.2.3.4' }), 'compete', undefined, deps(env)) + + expect([...db.counts.keys()].map((k) => k.split('|')[0]).sort()).toEqual([ + 'ip:1.2.3.4:compete', + 'ip:1.2.3.4:compete:daily', + ]) + }) + + it('checks no daily cap for a family that has none configured', async () => { + await enforceRateLimit(post({ 'x-forwarded-for': '1.2.3.4' }), 'build', undefined, deps()) + expect([...db.counts.keys()].map((k) => k.split('|')[0])).toEqual(['ip:1.2.3.4:build']) + }) + it('also counts the account when one is given', async () => { const env = { RATE_LIMIT_SUBMIT: '1/60' } const account = 'G'.padEnd(56, 'A') diff --git a/apps/dapp/lib/server/rate-limit.ts b/apps/dapp/lib/server/rate-limit.ts index 0df78a1e..df852e07 100644 --- a/apps/dapp/lib/server/rate-limit.ts +++ b/apps/dapp/lib/server/rate-limit.ts @@ -36,12 +36,36 @@ export interface RateLimitWindow { export const RATE_LIMIT_DEFAULTS: Readonly> = { build: { limit: 30, windowSeconds: 60 }, submit: { limit: 10, windowSeconds: 60 }, - compete: { limit: 10, windowSeconds: 60 }, + // Four a minute, not ten: compete is the one family where every request + // is several paid model calls (DeepSeek, Claude, up to two OpenRouter + // seats), so the per-minute window bounds burst cost. It is not the real + // backstop against sustained abuse — that is the daily cap below, because + // a caller who rotates IPs (or never connects a wallet) is still bound by + // it even though the per-minute window resets for them every time. + compete: { limit: 4, windowSeconds: 60 }, resolve: { limit: 30, windowSeconds: 60 }, standing: { limit: 30, windowSeconds: 60 }, auth: { limit: 10, windowSeconds: 60 }, } +/** + * A second, independent cap alongside the per-minute window, for families + * where the per-minute number alone does not bound a day's worst case. + * Checked as its own key in its own (much longer) window, so a caller who + * never trips the per-minute limit can still be stopped from sustaining it + * for hours. Absent for every family but `compete` today: the others are + * either free (OpenRouter, Groq) or already bounded elsewhere (`submit` + * spends the sponsor's balance, which is its own backstop). + * + * 150/day keeps a single key's worst-case Claude spend at roughly $2 (150 + * races * ~$0.0136/race, from providers.ts's own pricing) and keeps one + * caller from alone exhausting OpenRouter's shared 50-request daily free + * allowance or a meaningful slice of Groq's. + */ +export const RATE_LIMIT_DAILY: Readonly>> = { + compete: { limit: 150, windowSeconds: 24 * 60 * 60 }, +} + type Env = Record /** @@ -60,6 +84,26 @@ export function limitFor(family: RateLimitFamily, env: Env = process.env): RateL return RATE_LIMIT_DEFAULTS[family] } +/** + * The family's daily cap, or the `RATE_LIMIT__DAILY=count/seconds` + * override when it parses. Undefined when the family has no daily cap and + * none was configured — `enforceRateLimit` then checks only the per-minute + * window, as every family did before this existed. + */ +export function dailyLimitFor( + family: RateLimitFamily, + env: Env = process.env +): RateLimitWindow | undefined { + const raw = env[`RATE_LIMIT_${family.toUpperCase()}_DAILY`] + const match = raw === undefined ? null : /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(raw) + if (match !== null) { + const limit = Number(match[1]) + const windowSeconds = Number(match[2]) + if (limit > 0 && windowSeconds > 0) return { limit, windowSeconds } + } + return RATE_LIMIT_DAILY[family] +} + export interface RateLimitInput { key: string limit: number @@ -266,10 +310,12 @@ export async function enforceRateLimit( deps?: RateLimitDeps ): Promise { const window = limitFor(family, deps?.env) + const daily = dailyLimitFor(family, deps?.env) // With several networks served the allowance is per network, so use on one // cannot run down the other's. With one, the keys are what they always were. const scope = isMultiNetwork() ? `:${activeNetwork()}` : '' - const keys = [`ip:${clientIp(request, deps?.env)}:${family}${scope}`] + const ip = clientIp(request, deps?.env) + const keys = [`ip:${ip}:${family}${scope}`] if (account !== undefined && account !== '') keys.push(`account:${account}:${family}${scope}`) for (const key of keys) { @@ -280,6 +326,18 @@ export async function enforceRateLimit( { status: 429, headers: { 'Retry-After': String(decision.retryAfterSeconds) } } ) } + // A distinct key, not a second check on the same one: the two windows + // are different sizes, and sharing a key would mean the daily cap's + // 24-hour window also governed the per-minute count. + if (daily !== undefined) { + const dailyDecision = await checkRateLimit({ key: `${key}:daily`, ...daily }, deps) + if (!dailyDecision.allowed) { + return NextResponse.json( + { error: 'rate_limited', retryAfter: dailyDecision.retryAfterSeconds }, + { status: 429, headers: { 'Retry-After': String(dailyDecision.retryAfterSeconds) } } + ) + } + } } return undefined } From 37d1f9ad24f681745324696092ee5e5394334051 Mon Sep 17 00:00:00 2001 From: Depo-dev <24318346+Depo-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:59:58 +0100 Subject: [PATCH 2/7] feat(dapp): pause the sign button for a couple seconds on a $500+ swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a second signature gate — the wallet's own prompt still owns that — but a forced pause on the one screen most likely to be misread in a hurry, for the mistake a wallet prompt does nothing to catch: a fat-fingered amount, a plan that looks fine at a glance. Threshold is the larger of what is spent and received, configurable via NEXT_PUBLIC_SWAP_CONFIRM_THRESHOLD_USD, kept deliberately separate from trade-cap.ts's MAINNET_MAX_TRADE_USD hard cap even though both default to $500 today — one is a server-side refusal, this is a client-side pause, and they should be free to move independently later. --- apps/dapp/components/intents/swap-confirm.tsx | 89 ++++++++++++++++++- apps/dapp/lib/__tests__/confirm-pause.test.ts | 31 +++++++ apps/dapp/lib/swap/confirm-pause.ts | 34 +++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 apps/dapp/lib/__tests__/confirm-pause.test.ts create mode 100644 apps/dapp/lib/swap/confirm-pause.ts 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 ? ( + + View the swap + +
+ + ) : null} + + ) : null} {sequence.kind === 'swap-then-naira' && @@ -2385,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/lib/__tests__/pending-cashout.test.ts b/apps/dapp/lib/__tests__/pending-cashout.test.ts new file mode 100644 index 00000000..0bd55252 --- /dev/null +++ b/apps/dapp/lib/__tests__/pending-cashout.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { setHistoryOwner } from '../history-owner' +import { clearPendingCashOut, pendingCashOut, setPendingCashOut } from '../pending-cashout' + +/** + * A reminder for a swap that settled meaning to be cashed out, left behind by + * a closed tab. Local-only: see the module's own header for why this is not + * a `ChatTurn` field. + */ + +const store = new Map() + +beforeEach(() => { + store.clear() + setHistoryOwner(undefined) + vi.stubGlobal('window', { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }, + }) +}) + +describe('pendingCashOut', () => { + it('is undefined when nothing was set', () => { + expect(pendingCashOut()).toBeUndefined() + }) + + it('returns what was set', () => { + setPendingCashOut('HASH1', '82.4') + expect(pendingCashOut()).toMatchObject({ swapHash: 'HASH1', receiveDisplay: '82.4' }) + }) + + it('clears', () => { + setPendingCashOut('HASH1', '82.4') + clearPendingCashOut() + expect(pendingCashOut()).toBeUndefined() + }) + + it('overwrites a previous reminder rather than keeping both', () => { + setPendingCashOut('HASH1', '82.4') + setPendingCashOut('HASH2', '10') + expect(pendingCashOut()).toMatchObject({ swapHash: 'HASH2', receiveDisplay: '10' }) + }) + + it('keeps one wallet from reading another’s reminder in the same browser', () => { + setHistoryOwner('GFIRST') + setPendingCashOut('HASH1', '82.4') + + setHistoryOwner('GSECOND') + expect(pendingCashOut()).toBeUndefined() + + setHistoryOwner('GFIRST') + expect(pendingCashOut()).toMatchObject({ swapHash: 'HASH1' }) + }) + + it('ignores a value that does not parse as a reminder', () => { + store.set('intent.pending-cashout.v1', 'not json') + expect(pendingCashOut()).toBeUndefined() + }) + + it('does not throw when storage is unavailable', () => { + vi.stubGlobal('window', { + localStorage: { + getItem: () => { + throw new Error('blocked') + }, + setItem: () => { + throw new Error('blocked') + }, + removeItem: () => { + throw new Error('blocked') + }, + }, + }) + expect(() => setPendingCashOut('HASH1', '82.4')).not.toThrow() + expect(pendingCashOut()).toBeUndefined() + expect(() => clearPendingCashOut()).not.toThrow() + }) +}) diff --git a/apps/dapp/lib/pending-cashout.ts b/apps/dapp/lib/pending-cashout.ts new file mode 100644 index 00000000..84f62cc0 --- /dev/null +++ b/apps/dapp/lib/pending-cashout.ts @@ -0,0 +1,70 @@ +import { historyOwner } from './history-owner' + +/** + * A swap that settled meaning to be followed by a naira cash-out, where the + * cash-out order was never actually created — because the tab closed, the + * browser crashed, or the user navigated away before reaching the bank-detail + * form. + * + * Local-only, not a `ChatTurn` field: `conversation-client.ts`'s + * `saveTurnRemote`/`updateTurnRemote` only forward a fixed, named set of + * fields to the server, so a new field added to `ChatTurn` would be silently + * dropped on sync rather than persisted — exactly the kind of lost state this + * exists to prevent. This is deliberately scoped smaller: it survives this + * browser closing and reopening, which is the actual gap (the swap itself is + * already in history once it settles; what was missing was the reminder that + * a second step was still owed). + */ + +const STORAGE_BASE = 'intent.pending-cashout.v1' + +function storageKey(): string { + const owner = historyOwner() + return owner === undefined ? STORAGE_BASE : `${STORAGE_BASE}:${owner}` +} + +export interface PendingCashOut { + /** The swap's transaction hash, so the reminder can link back to it. */ + swapHash: string + /** What the swap delivered, for "cash out the ~82 USDC still waiting". */ + receiveDisplay: string + setAt: string +} + +/** Marks a swap as settled with its cash-out not yet started. Overwrites any previous one. */ +export function setPendingCashOut(swapHash: string, receiveDisplay: string): void { + try { + const value: PendingCashOut = { swapHash, receiveDisplay, setAt: new Date().toISOString() } + window.localStorage.setItem(storageKey(), JSON.stringify(value)) + } catch { + // Storage can be full or blocked; the reminder is a convenience, not a + // guarantee, so a write failure here must not break the swap flow itself. + } +} + +/** The pending cash-out for this wallet, if one was left behind. */ +export function pendingCashOut(): PendingCashOut | undefined { + try { + const raw = window.localStorage.getItem(storageKey()) + if (raw === null) return undefined + const parsed = JSON.parse(raw) as Partial + return typeof parsed.swapHash === 'string' && typeof parsed.receiveDisplay === 'string' + ? { + swapHash: parsed.swapHash, + receiveDisplay: parsed.receiveDisplay, + setAt: parsed.setAt ?? '', + } + : undefined + } catch { + return undefined + } +} + +/** Clears the reminder: the cash-out was started (an order exists) or the user moved on. */ +export function clearPendingCashOut(): void { + try { + window.localStorage.removeItem(storageKey()) + } catch { + // Same reasoning as the write above. + } +} From 2f4165156e6750181e893edb9507a8013cf32509 Mon Sep 17 00:00:00 2001 From: Depo-dev <24318346+Depo-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:32:08 +0100 Subject: [PATCH 5/7] feat(dapp): check agent health on a daily cron, and alert on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces running curl against production and reading Vercel logs by hand to notice a dead or rate-limited model. GET /api/agents/health reads the races already logged rather than running a synthetic race of its own, so it costs nothing beyond a couple of queries. A "core" agent (anything but OpenRouter, whose free models rotate and get benched on their own limits as normal operation) that answered zero times in the last 24h is critical; a roster-wide answer rate under 50% is a warning, zero is critical; fewer than 3 races in the window is reported as not enough to judge rather than guessed at. Authenticated with CRON_SECRET the way Vercel Cron actually calls a job — GET, Authorization: Bearer — not the POST and custom header standing/tick uses, which was never wired to a scheduler (no vercel.json existed before this). The account's plan is Hobby, which caps cron to once a day, so that is the cadence. An alert reuses the existing alerts_sent/sendAlert path the sponsor-balance alert already sends through, rather than a new, unconfigured webhook integration. --- apps/dapp/app/api/agents/health/route.ts | 85 ++++++++++ .../lib/__tests__/agent-health-route.test.ts | 111 ++++++++++++ apps/dapp/lib/__tests__/agent-health.test.ts | 101 +++++++++++ apps/dapp/lib/server/agent-health.ts | 158 ++++++++++++++++++ apps/dapp/vercel.json | 8 + 5 files changed, 463 insertions(+) create mode 100644 apps/dapp/app/api/agents/health/route.ts create mode 100644 apps/dapp/lib/__tests__/agent-health-route.test.ts create mode 100644 apps/dapp/lib/__tests__/agent-health.test.ts create mode 100644 apps/dapp/lib/server/agent-health.ts create mode 100644 apps/dapp/vercel.json 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/lib/__tests__/agent-health-route.test.ts b/apps/dapp/lib/__tests__/agent-health-route.test.ts new file mode 100644 index 00000000..f6ec1056 --- /dev/null +++ b/apps/dapp/lib/__tests__/agent-health-route.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * GET /api/agents/health: Vercel Cron's actual invocation shape — a GET + * request carrying `Authorization: Bearer ` — not the POST and + * custom header the older standing/tick route uses. + */ + +const query = vi.fn() +vi.mock('../server/db', () => ({ + getPool: () => ({ query: (...a: unknown[]) => query(...a) }), + withTimeout: (p: Promise) => p, +})) + +const sendAlert = vi.fn() +vi.mock('../server/email', () => ({ getEmailSender: () => ({ sendAlert }) })) + +const wasSent = vi.fn().mockResolvedValue(false) +const markSent = vi.fn() +vi.mock('../server/alerts', () => ({ + alertRecipient: () => 'ops@example.com', + getAlertsRepo: async () => ({ wasSent, markSent }), +})) + +const SECRET = 'a-secret-at-least-16-chars' + +function get(headers: Record = {}): Request { + return new Request('http://localhost/api/agents/health', { headers }) +} + +beforeEach(() => { + query.mockReset() + sendAlert.mockReset() + wasSent.mockReset().mockResolvedValue(false) + markSent.mockReset() + process.env['CRON_SECRET'] = SECRET + // Healthy by default: plenty of races, everything answered. + query.mockImplementation(async (sql: string) => { + const s = sql.replace(/\s+/g, ' ').trim() + if (s.startsWith('SELECT count(*) AS n')) { + return { rows: [{ n: '10', asked: '40', answered: '40' }] } + } + return { rows: [{ agent: 'anthropic:claude-sonnet-5-5', races: '10', answered: '10' }] } + }) +}) + +describe('GET /api/agents/health', () => { + it('refuses with no secret configured', async () => { + delete process.env['CRON_SECRET'] + const { GET } = await import('../../app/api/agents/health/route') + const res = await GET(get({ authorization: `Bearer ${SECRET}` })) + expect(res.status).toBe(503) + expect(query).not.toHaveBeenCalled() + }) + + it('refuses a request with no Authorization header', async () => { + const { GET } = await import('../../app/api/agents/health/route') + const res = await GET(get()) + expect(res.status).toBe(403) + }) + + it('refuses the wrong secret', async () => { + const { GET } = await import('../../app/api/agents/health/route') + const res = await GET(get({ authorization: 'Bearer wrong' })) + expect(res.status).toBe(403) + }) + + it("accepts Vercel's own Bearer format and returns the report", async () => { + const { GET } = await import('../../app/api/agents/health/route') + const res = await GET(get({ authorization: `Bearer ${SECRET}` })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.severity).toBe('ok') + }) + + it('sends an alert on critical, and marks it sent', async () => { + query.mockImplementation(async (sql: string) => { + const s = sql.replace(/\s+/g, ' ').trim() + if (s.startsWith('SELECT count(*) AS n')) { + return { rows: [{ n: '10', asked: '10', answered: '0' }] } + } + return { rows: [] } + }) + const { GET } = await import('../../app/api/agents/health/route') + await GET(get({ authorization: `Bearer ${SECRET}` })) + + expect(sendAlert).toHaveBeenCalledTimes(1) + expect(markSent).toHaveBeenCalledTimes(1) + }) + + it('does not alert twice in the same day', async () => { + wasSent.mockResolvedValue(true) + query.mockImplementation(async (sql: string) => { + const s = sql.replace(/\s+/g, ' ').trim() + if (s.startsWith('SELECT count(*) AS n')) { + return { rows: [{ n: '10', asked: '10', answered: '0' }] } + } + return { rows: [] } + }) + const { GET } = await import('../../app/api/agents/health/route') + await GET(get({ authorization: `Bearer ${SECRET}` })) + + expect(sendAlert).not.toHaveBeenCalled() + }) + + it('does not alert when the report is ok', async () => { + const { GET } = await import('../../app/api/agents/health/route') + await GET(get({ authorization: `Bearer ${SECRET}` })) + expect(sendAlert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/dapp/lib/__tests__/agent-health.test.ts b/apps/dapp/lib/__tests__/agent-health.test.ts new file mode 100644 index 00000000..c0a21f3c --- /dev/null +++ b/apps/dapp/lib/__tests__/agent-health.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import { readAgentHealth, reportFrom } from '../server/agent-health' + +/** + * Agent-health is read from the races the app already logs, not from a + * synthetic check — see the module's own header for why. What is under + * test: the thresholds (`reportFrom`) and the SQL shape (`readAgentHealth` + * against a faked query function matched by statement shape). + */ + +describe('reportFrom', () => { + const window = { hours: 24, races: 10 } + + it('is insufficient_data with too few races to judge', () => { + const r = reportFrom('mainnet', { hours: 24, races: 1 }, [], 0, 0) + expect(r.severity).toBe('insufficient_data') + }) + + it('is ok when every agent answered', () => { + const r = reportFrom( + 'mainnet', + window, + [{ agent: 'anthropic:claude-sonnet-5-5', races: 10, answered: 10 }], + 40, + 40 + ) + expect(r.severity).toBe('ok') + expect(r.reasons).toEqual([]) + }) + + it('is critical when a core agent answered zero times', () => { + const r = reportFrom( + 'mainnet', + window, + [ + { agent: 'anthropic:claude-sonnet-5-5', races: 10, answered: 0 }, + { agent: 'deepseek:deepseek-v4-flash', races: 10, answered: 10 }, + ], + 10, + 20 + ) + expect(r.severity).toBe('critical') + expect(r.reasons[0]).toContain('anthropic:claude-sonnet-5-5 answered 0 of 10') + }) + + it('is critical when nobody answered anything', () => { + const r = reportFrom('mainnet', window, [], 0, 10) + expect(r.severity).toBe('critical') + expect(r.reasons[0]).toContain('no agent answered') + }) + + it('is warning when the answer rate is low but not zero', () => { + const r = reportFrom('mainnet', window, [], 3, 10) + expect(r.severity).toBe('warning') + }) + + it('does not flag a core agent with zero asks as dead', () => { + // Not configured this window, not failing: races === 0 means "not asked", + // which is a different fact from "asked and never answered". + const r = reportFrom( + 'mainnet', + window, + [{ agent: 'ollama:qwen3.5', races: 0, answered: 0 }], + 10, + 10 + ) + expect(r.severity).toBe('ok') + }) + + it('leaves answerRate undefined rather than dividing by zero', () => { + const r = reportFrom('mainnet', { hours: 24, races: 5 }, [], 0, 0) + expect(r.answerRate).toBeUndefined() + }) +}) + +describe('readAgentHealth', () => { + function fakeQuery(races: Record, agents: Record[]) { + return async (sql: string) => { + const s = sql.replace(/\s+/g, ' ').trim() + if (s.startsWith('SELECT count(*) AS n')) return { rows: [races] } + if (s.startsWith('SELECT p.agent')) return { rows: agents } + throw new Error(`unrecognised statement: ${s}`) + } + } + + it('reports core-agent rows only, from the joined query', async () => { + const query = fakeQuery({ n: '10', asked: '40', answered: '38' }, [ + { agent: 'anthropic:claude-sonnet-5-5', races: '10', answered: '10' }, + { agent: 'openrouter:google/gemma-4-26b-a4b-it:free', races: '10', answered: '2' }, + ]) + const report = await readAgentHealth(query, 'mainnet', 24, new Date('2026-10-27T00:00:00Z')) + + expect(report.coreAgents).toEqual([ + { agent: 'anthropic:claude-sonnet-5-5', races: 10, answered: 10 }, + ]) + expect(report.askedTotal).toBe(40) + expect(report.answeredTotal).toBe(38) + expect(report.severity).toBe('ok') + }) +}) diff --git a/apps/dapp/lib/server/agent-health.ts b/apps/dapp/lib/server/agent-health.ts new file mode 100644 index 00000000..322200fa --- /dev/null +++ b/apps/dapp/lib/server/agent-health.ts @@ -0,0 +1,158 @@ +import { activeNetwork } from '@intent/config' + +import { getPool, withTimeout, type QueryFn } from './db' + +/** + * Whether the agent roster is actually answering, read from the races the + * app already logs — not a synthetic check that runs its own race. + * + * A dedicated active check (calling `brain.propose()` on a few cheap agents + * on a timer) was the alternative; this is cheaper and catches exactly what + * a human running curl against production was catching by hand: real + * requests from real traffic failing. Its blind spot — a quiet deployment + * with no traffic for the window — is accepted rather than paid for with a + * synthetic race that spends the paid agent's own budget just to prove it + * is reachable. + * + * "Core" agents are everything but OpenRouter: OpenRouter's free models + * rotate and get benched on their own free-tier limits (see `lineup.ts`), + * which is normal operation, not an outage, so a single OpenRouter model + * failing never triggers an alert on its own. + */ + +const CORE_PROVIDERS = ['anthropic', 'deepseek', 'groq', 'ollama'] as const + +export interface AgentHealthWindow { + /** How far back the window reaches. */ + hours: number + /** Races observed in the window — too few to judge is its own signal. */ + races: number +} + +export interface CoreAgentHealth { + agent: string + races: number + answered: number +} + +export interface AgentHealthReport { + network: string + window: AgentHealthWindow + /** Every "core" agent configured, i.e. seen in this window — those that answered zero times are the critical signal. */ + coreAgents: CoreAgentHealth[] + /** Across every agent, core and OpenRouter alike. */ + answeredTotal: number + askedTotal: number + /** `undefined` when `askedTotal` is zero — nothing to divide. */ + answerRate: number | undefined + /** + * The verdict this report's numbers support. Computed here rather than by + * the caller, so the thresholds live in one place: too few races to judge + * (fewer than `MIN_RACES_TO_JUDGE`), a dead core agent, a roster-wide + * answer rate that is low or zero. + */ + severity: 'ok' | 'warning' | 'critical' | 'insufficient_data' + /** One line per reason `severity` is what it is; empty when `severity` is 'ok'. */ + reasons: string[] +} + +/** Below this many races in the window, a rate is noise, not a signal. */ +const MIN_RACES_TO_JUDGE = 3 + +export function reportFrom( + network: string, + window: AgentHealthWindow, + coreAgents: CoreAgentHealth[], + answeredTotal: number, + askedTotal: number +): AgentHealthReport { + const answerRate = askedTotal > 0 ? answeredTotal / askedTotal : undefined + const reasons: string[] = [] + + if (window.races < MIN_RACES_TO_JUDGE) { + return { + network, + window, + coreAgents, + answeredTotal, + askedTotal, + answerRate, + severity: 'insufficient_data', + reasons: [`only ${window.races} race(s) in the last ${window.hours}h — too few to judge`], + } + } + + const deadCore = coreAgents.filter((a) => a.races > 0 && a.answered === 0) + for (const a of deadCore) { + reasons.push(`${a.agent} answered 0 of ${a.races} races in the last ${window.hours}h`) + } + + let severity: AgentHealthReport['severity'] = 'ok' + if (answerRate !== undefined && answerRate === 0) { + severity = 'critical' + reasons.push(`no agent answered any of ${askedTotal} asks in the last ${window.hours}h`) + } else if (deadCore.length > 0) { + severity = 'critical' + } else if (answerRate !== undefined && answerRate < 0.5) { + severity = 'warning' + reasons.push( + `only ${(answerRate * 100).toFixed(0)}% of asks were answered in the last ${window.hours}h` + ) + } + + return { network, window, coreAgents, answeredTotal, askedTotal, answerRate, severity, reasons } +} + +/** Reads the report for one network, over a rolling window ending now. */ +export async function readAgentHealth( + query: QueryFn, + network: string = activeNetwork(), + hours = 24, + now: Date = new Date() +): Promise { + const since = new Date(now.getTime() - hours * 3_600_000).toISOString() + + const [{ rows: raceRows }, { rows: agentRows }] = await Promise.all([ + query( + `SELECT count(*) AS n, coalesce(sum(agents), 0) AS asked, coalesce(sum(answered), 0) AS answered + FROM agent_races WHERE network = $1 AND started_at >= $2`, + [network, since] + ), + query( + `SELECT p.agent, count(*) AS races, count(*) FILTER (WHERE p.ok) AS answered + FROM agent_proposals p JOIN agent_races r ON r.id = p.race_id + WHERE r.network = $1 AND r.started_at >= $2 + GROUP BY p.agent`, + [network, since] + ), + ]) + + const races = Number(raceRows[0]?.['n'] ?? 0) + const askedTotal = Number(raceRows[0]?.['asked'] ?? 0) + const answeredTotal = Number(raceRows[0]?.['answered'] ?? 0) + + const coreAgents: CoreAgentHealth[] = agentRows + .map((row) => ({ + agent: String(row['agent']), + races: Number(row['races']), + answered: Number(row['answered']), + })) + .filter((a) => + CORE_PROVIDERS.includes(a.agent.split(':')[0] as (typeof CORE_PROVIDERS)[number]) + ) + + return reportFrom(network, { hours, races }, coreAgents, answeredTotal, askedTotal) +} + +/** The production reader, over the shared pool. */ +export async function getAgentHealth(network?: string, hours?: number): Promise { + const pool = getPool() + return readAgentHealth( + async (sql, params) => { + const result = await withTimeout(pool.query(sql, params), 8_000) + return { rows: result.rows as Record[] } + }, + network, + hours + ) +} diff --git a/apps/dapp/vercel.json b/apps/dapp/vercel.json new file mode 100644 index 00000000..3af81972 --- /dev/null +++ b/apps/dapp/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/agents/health", + "schedule": "0 7 * * *" + } + ] +} From ed94db76607a417e90359dc699d41cfccecb5583 Mon Sep 17 00:00:00 2001 From: Depo-dev <24318346+Depo-dev@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:41:12 +0100 Subject: [PATCH 6/7] feat(dapp): phoenix as a third mainnet swap source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two open risks the design doc flagged before any code was written, both confirmed live against mainnet, not assumed from source: - ask_amount is the real destination amount; total_return is the pre-fee idealised one. ask_amount + commission_amount + spread_amount == total_return exactly, on two independent live swaps in opposite directions. Using total_return would have overstated what a user receives by the fee and the spread. - Phoenix has no router to pin a static allowlist entry to. The quote carries the exact pool address it priced, the builder signs only that pool, and the post-signature re-check (which has no quote to compare against) checks shape only — matching, not loosening, what Aquarius's own re-check already does. A live build-sign-simulate round trip against the real factory and a real pool is in build-phoenix.live.test.ts, which is what caught two real bugs before they shipped: Address.fromScVal doesn't decode a raw ScAddress (fromScAddress does), and an Option None is scvVoid, not a nativeToScVal({type:'void'}) call. Mainnet only - no testnet deployment exists to point at. Multihop (routing through pairs with no direct pool) is out of scope: a throwaway encoding attempt failed and a direct quoter needs nothing from it. --- apps/dapp/app/api/swap/build/route.ts | 82 ++++++ .../lib/__tests__/aggregator-routes.test.ts | 1 + .../lib/__tests__/build-phoenix.live.test.ts | 120 ++++++++ apps/dapp/lib/__tests__/build-phoenix.test.ts | 217 ++++++++++++++ .../contract-registry-networks.test.ts | 12 + .../dapp/lib/__tests__/phoenix-quoter.test.ts | 201 +++++++++++++ .../lib/__tests__/venue-integration.test.ts | 19 +- .../dapp/lib/__tests__/venue-networks.test.ts | 22 +- apps/dapp/lib/__tests__/venue-routing.test.ts | 50 ++++ apps/dapp/lib/agents/market-context.ts | 2 + apps/dapp/lib/swap/build-phoenix.ts | 269 ++++++++++++++++++ apps/dapp/lib/swap/contract-registry.ts | 39 +++ apps/dapp/lib/swap/quote.ts | 14 +- apps/dapp/lib/swap/sources/phoenix-quoter.ts | 239 ++++++++++++++++ apps/dapp/lib/swap/venue-routing.ts | 20 +- apps/dapp/lib/venues.ts | 17 ++ 16 files changed, 1307 insertions(+), 17 deletions(-) create mode 100644 apps/dapp/lib/__tests__/build-phoenix.live.test.ts create mode 100644 apps/dapp/lib/__tests__/build-phoenix.test.ts create mode 100644 apps/dapp/lib/__tests__/phoenix-quoter.test.ts create mode 100644 apps/dapp/lib/swap/build-phoenix.ts create mode 100644 apps/dapp/lib/swap/sources/phoenix-quoter.ts 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/lib/__tests__/aggregator-routes.test.ts b/apps/dapp/lib/__tests__/aggregator-routes.test.ts index 4ba3bee2..39427b03 100644 --- a/apps/dapp/lib/__tests__/aggregator-routes.test.ts +++ b/apps/dapp/lib/__tests__/aggregator-routes.test.ts @@ -64,6 +64,7 @@ describe('presence in the line-up', () => { expect(ids).toContain('soroswap') expect(ids).toContain('aquarius') expect(ids).toContain('soroswap-aggregator') + expect(ids).toContain('phoenix') }) it('is skipped, not failed, when unconfigured', async () => { diff --git a/apps/dapp/lib/__tests__/build-phoenix.live.test.ts b/apps/dapp/lib/__tests__/build-phoenix.live.test.ts new file mode 100644 index 00000000..f2675a95 --- /dev/null +++ b/apps/dapp/lib/__tests__/build-phoenix.live.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +/** + * A Phoenix swap, simulated against the live pool, on mainnet. + * + * Phoenix has no testnet instance (see `contract-registry.ts`'s + * `phoenixFactory`), so unlike the Aquarius equivalent this runs against + * mainnet — read-only: nothing here signs or submits, and `prepareSorobanSwap` + * only simulates. The network is stubbed for the module graph this test + * needs, the same way `contract-registry-networks.test.ts` does, rather than + * assuming the ambient test environment happens to be mainnet. + * + * This is the check that catches an `i128` encoded as `u128`, an `Option` + * encoded wrong, or a pool address the factory no longer recognises — each + * of which fails in simulation with an error that names none of those + * things, and none of which a fixture written from this module's own + * assumptions would ever see. + */ + +const SKIP = process.env['SKIP_LIVE'] === '1' +const ME = 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA' + +type Loaded = { + resolveAsset: typeof import('../swap/assets').resolveAsset + buildPhoenixSwap: typeof import('../swap/build-phoenix').buildPhoenixSwap + prepareSorobanSwap: typeof import('../swap/build-soroban').prepareSorobanSwap + // Built, not just imported, while the mainnet stub is still active: the + // quoter's factory id is resolved once, at construction, by reading + // activeNetwork() through phoenixFactory() — resolving it later, after + // vi.unstubAllEnvs() has already run, would silently build it against + // testnet (where Phoenix has no id) and every quote would come back + // "unsupported_pair" with no hint that the network, not Phoenix, was wrong. + quoter: import('../swap/quote').QuoteSource +} + +let mod: Loaded + +beforeAll(async () => { + vi.resetModules() + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'mainnet') + const [{ resolveAsset }, { buildPhoenixSwap }, { prepareSorobanSwap }, { createPhoenixQuoter }] = + await Promise.all([ + import('../swap/assets'), + import('../swap/build-phoenix'), + import('../swap/build-soroban'), + import('../swap/sources/phoenix-quoter'), + ]) + const quoter = createPhoenixQuoter() + mod = { resolveAsset, buildPhoenixSwap, prepareSorobanSwap, quoter } + // Deliberately not unstubbed here: buildPhoenixSwap and assertVenueOn also + // read activeNetwork() at call time, inside the tests below, not only at + // construction. The stub stays live for the whole suite and is removed + // only once nothing here will call activeNetwork() again. +}, 30_000) + +afterAll(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) + +describe.skipIf(SKIP)('a Phoenix swap the pool accepts', () => { + it('quotes, builds against the quoted pool, and simulates clean', async () => { + const XLM = mod.resolveAsset('XLM') + const USDC = mod.resolveAsset('USDC') + if (XLM === undefined || USDC === undefined) throw new Error('registry missing XLM or USDC') + + const quote = await mod.quoter.quote({ + kind: 'strict_send', + from: XLM, + to: USDC, + sendAmount: '10000000', // 1 XLM + }) + if (!quote.ok) throw new Error(`no quote: ${quote.failure.reason}`) + if (quote.quote.poolAddress === undefined) throw new Error('quote carried no pool address') + + // A floor well under the quote, so the simulation tests the call shape + // and not the price having moved between the two calls. + const minReceive = (BigInt(quote.quote.destAmount) / BigInt(2)).toString() + + const built = await mod.buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '10000000', + minReceive, + poolAddress: quote.quote.poolAddress, + }) + + const prepared = await mod.prepareSorobanSwap(built.xdr) + expect(prepared.ok).toBe(true) + if (!prepared.ok) throw new Error(prepared.reason) + }, 60_000) + + it('is refused by the pool when the floor is above what it would pay', async () => { + const XLM = mod.resolveAsset('XLM') + const USDC = mod.resolveAsset('USDC') + if (XLM === undefined || USDC === undefined) throw new Error('registry missing XLM or USDC') + + const quote = await mod.quoter.quote({ + kind: 'strict_send', + from: XLM, + to: USDC, + sendAmount: '10000000', + }) + if (!quote.ok || quote.quote.poolAddress === undefined) throw new Error('no quote') + + const tooHigh = (BigInt(quote.quote.destAmount) * BigInt(10)).toString() + const built = await mod.buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '10000000', + minReceive: tooHigh, + poolAddress: quote.quote.poolAddress, + }) + + const prepared = await mod.prepareSorobanSwap(built.xdr) + expect(prepared.ok).toBe(false) + }, 60_000) +}) diff --git a/apps/dapp/lib/__tests__/build-phoenix.test.ts b/apps/dapp/lib/__tests__/build-phoenix.test.ts new file mode 100644 index 00000000..7b4994dd --- /dev/null +++ b/apps/dapp/lib/__tests__/build-phoenix.test.ts @@ -0,0 +1,217 @@ +import { + Account, + Address, + BASE_FEE, + Contract, + Networks, + TransactionBuilder, + nativeToScVal, + xdr, +} from '@stellar/stellar-sdk' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { resolveAsset } from '../swap/assets' +import { assertSelfPhoenixSwap, buildPhoenixSwap } from '../swap/build-phoenix' +import { sacFor } from '../swap/build-soroban' + +/** + * Building a Phoenix swap, without a network. + * + * What is pinned is the assertion, because that is what stands between a + * built envelope and a signature. The pool pays `sender` whatever the ask + * asset's amount is, so the assertion has to prove that argument is the + * signer — read back out of the bytes, not trusted from the inputs — and, + * unlike Aquarius's single router, also prove the call *target itself* is + * the one pool this quote priced, since Phoenix has no router argument to + * pin instead. + */ + +const ME = 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA' +const STRANGER = 'GAWWM4J4W3ZNGFQR4ULIQCNY44EHLBLVARBHM5CSXOGJYGZVDIBFGVC5' +const POOL = 'CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX' +const OTHER_POOL = 'CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH' + +const XLM = resolveAsset('XLM') +const USDC = resolveAsset('USDC') +if (XLM === undefined || USDC === undefined) throw new Error('registry missing XLM or USDC') + +// Phoenix is mainnet-only (see contract-registry.ts's phoenixFactory), and +// assertVenueOn checks that before anything else build-phoenix.ts does. +// Every test below is about what happens after that check, so the network +// is stubbed to the one network Phoenix is actually on. +beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'mainnet') +}) +afterEach(() => { + vi.unstubAllEnvs() +}) + +/** Horizon answering with a sequence number and nothing else. */ +const sequenceOnly: typeof fetch = (async () => + new Response(JSON.stringify({ sequence: '12345' }), { status: 200 })) as unknown as typeof fetch + +/** + * A `swap` envelope built by hand, so each argument — and the call target — + * can be substituted independently of the builder that would have refused. + */ +function handBuilt(opts: { + pool?: string + source?: string + sender?: string + fn?: string + argCount?: number +}): string { + const source = opts.source ?? ME + const sender = opts.sender ?? ME + const pool = opts.pool ?? POOL + const offerAsset = sacFor(XLM as NonNullable) + + const args: xdr.ScVal[] = [ + new Address(sender).toScVal(), + new Address(offerAsset).toScVal(), + nativeToScVal(BigInt('10000000'), { type: 'i128' }), + nativeToScVal(BigInt('1'), { type: 'i128' }), + xdr.ScVal.scvVoid(), + xdr.ScVal.scvVoid(), + xdr.ScVal.scvVoid(), + ].slice(0, opts.argCount ?? 7) + + return new TransactionBuilder(new Account(source, '1'), { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(new Contract(pool).call(opts.fn ?? 'swap', ...args)) + .setTimeout(180) + .build() + .toXDR() +} + +describe('building a swap', () => { + it('refuses on testnet, where Phoenix has no deployment', async () => { + vi.unstubAllEnvs() + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'testnet') + await expect( + buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '10000000', + minReceive: '1', + poolAddress: POOL, + fetchImpl: sequenceOnly, + }) + ).rejects.toThrow(/not on testnet/) + }) + + it('produces an envelope that passes its own assertion', async () => { + const built = await buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '10000000', + minReceive: '1', + poolAddress: POOL, + fetchImpl: sequenceOnly, + }) + + expect(built.recipient).toBe(ME) + expect(built.poolAddress).toBe(POOL) + expect(() => assertSelfPhoenixSwap(built.xdr, ME, POOL)).not.toThrow() + }) + + it('refuses to build without a pool address', async () => { + // "Whichever pool is best" at signing time is not the pool the agent + // chose. The builder does not get to decide. + await expect( + buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '10000000', + minReceive: '1', + poolAddress: '', + fetchImpl: sequenceOnly, + }) + ).rejects.toThrow(/pool address/) + }) + + it('refuses a zero amount', async () => { + await expect( + buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '0', + minReceive: '1', + poolAddress: POOL, + fetchImpl: sequenceOnly, + }) + ).rejects.toThrow(/positive amount/) + }) + + it('refuses a negative minimum received', async () => { + await expect( + buildPhoenixSwap({ + account: ME, + from: XLM, + to: USDC, + sendAmount: '10000000', + minReceive: '-1', + poolAddress: POOL, + fetchImpl: sequenceOnly, + }) + ).rejects.toThrow(/minimum received/) + }) +}) + +describe('the swap must hit the quoted pool and pay the account that funds it', () => { + it('accepts an envelope against the quoted pool, signed by the sender', () => { + expect(() => assertSelfPhoenixSwap(handBuilt({}), ME, POOL)).not.toThrow() + }) + + it('refuses a call to a different pool than the one quoted', () => { + // The one check Aquarius's own assertion has no equivalent of: Phoenix + // has no router argument naming the pool, so the call target itself is + // the only place a substitution could hide. + expect(() => assertSelfPhoenixSwap(handBuilt({ pool: OTHER_POOL }), ME, POOL)).toThrow( + /not the.*pool.*this quote priced/ + ) + }) + + it('refuses one that pays somebody else', () => { + expect(() => assertSelfPhoenixSwap(handBuilt({ sender: STRANGER }), ME, POOL)).toThrow( + /pays .* not the signing account/ + ) + }) + + it('refuses one whose source is somebody else', () => { + expect(() => assertSelfPhoenixSwap(handBuilt({ source: STRANGER }), ME, POOL)).toThrow( + /transaction source/ + ) + }) + + it('refuses a function that is not swap', () => { + // `withdraw_liquidity` and `provide_liquidity` share the pool contract + // and are not swaps. + expect(() => assertSelfPhoenixSwap(handBuilt({ fn: 'withdraw_liquidity' }), ME, POOL)).toThrow( + /not a Phoenix swap/ + ) + }) + + it('refuses the wrong number of arguments', () => { + expect(() => assertSelfPhoenixSwap(handBuilt({ argCount: 6 }), ME, POOL)).toThrow( + /seven arguments/ + ) + }) + + it('checks only shape, not pool identity, when no pool address is given', () => { + // The re-check `venue-routing.ts` runs after signing has no quote to + // compare against — see assertSelfPhoenixSwap's own doc comment. A call + // to any pool, with the right shape, passes; only the function and + // recipient are checked. + expect(() => assertSelfPhoenixSwap(handBuilt({ pool: OTHER_POOL }), ME)).not.toThrow() + expect(() => assertSelfPhoenixSwap(handBuilt({ sender: STRANGER }), ME)).toThrow( + /pays .* not the signing account/ + ) + }) +}) diff --git a/apps/dapp/lib/__tests__/contract-registry-networks.test.ts b/apps/dapp/lib/__tests__/contract-registry-networks.test.ts index f3ede35b..307f64c2 100644 --- a/apps/dapp/lib/__tests__/contract-registry-networks.test.ts +++ b/apps/dapp/lib/__tests__/contract-registry-networks.test.ts @@ -103,6 +103,11 @@ describe('on testnet, nothing moved', () => { expect(registry.lookupContract(MAINNET.soroswapRouter)).toBeUndefined() expect(registry.lookupContract(MAINNET.blendPool)).toBeUndefined() }) + + it('leaves Phoenix undefined, the mirror of Noether, since it has no testnet deployment', async () => { + const { registry } = await on('testnet') + expect(registry.phoenixFactory()).toBeUndefined() + }) }) describe('on mainnet', () => { @@ -122,6 +127,13 @@ describe('on mainnet', () => { expect(soroswap.soroswapContracts()).toEqual({ XLM: MAINNET.xlmSac, USDC: MAINNET.usdcSac }) }) + it('resolves Phoenix’s factory, the mirror asymmetry: mainnet-only, not testnet-only', async () => { + const { registry } = await on('mainnet') + expect(registry.phoenixFactory()).toBe( + 'CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI' + ) + }) + it('leaves Noether undefined, because it has no mainnet deployment', async () => { const { registry } = await on('mainnet') expect(registry.noetherMarket()).toBeUndefined() diff --git a/apps/dapp/lib/__tests__/phoenix-quoter.test.ts b/apps/dapp/lib/__tests__/phoenix-quoter.test.ts new file mode 100644 index 00000000..32899d6d --- /dev/null +++ b/apps/dapp/lib/__tests__/phoenix-quoter.test.ts @@ -0,0 +1,201 @@ +import { Address, nativeToScVal, rpc, xdr, type Operation } from '@stellar/stellar-sdk' +import { describe, expect, it } from 'vitest' + +import { resolveAsset } from '../swap/assets' +import { sacFor } from '../swap/build-soroban' +import { createPhoenixQuoter } from '../swap/sources/phoenix-quoter' + +/** + * Quoting against Phoenix, without a network. + * + * The live test (`build-phoenix.live.test.ts`) is what actually proves the + * encoding and the field choice against the real factory and pool; this is + * the no_route and pool-resolution handling, matching + * `AquariusQuoterOptions.serverImpl`'s pattern so it is checkable without a + * network on every run, not only when SKIP_LIVE is unset. + */ + +const FACTORY = 'CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI' +const POOL = 'CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX' + +const XLM = resolveAsset('XLM') +const USDC = resolveAsset('USDC') +if (XLM === undefined || USDC === undefined) throw new Error('registry missing XLM or USDC') + +type Sim = Pick + +function invocationOf(tx: Parameters[0]): { + functionName?: unknown + args?: xdr.ScVal[] +} { + const op = (tx as { operations: Operation[] }).operations[0] as + | { func?: { invokeContract?: { functionName?: unknown; args?: xdr.ScVal[] } } } + | undefined + return op?.func?.invokeContract ?? {} +} + +function fnOf(tx: Parameters[0]): string { + return String(invocationOf(tx).functionName ?? '') +} + +function response(retval: xdr.ScVal): rpc.Api.SimulateTransactionSuccessResponse { + return { + id: '1', + latestLedger: 1, + events: [], + minResourceFee: '0', + result: { auth: [], retval }, + } as unknown as rpc.Api.SimulateTransactionSuccessResponse +} + +function errorResponse(message: string): rpc.Api.SimulateTransactionErrorResponse { + return { + id: '1', + latestLedger: 1, + events: [], + error: message, + } as unknown as rpc.Api.SimulateTransactionErrorResponse +} + +/** + * A fake factory + pool: `query_for_pool_by_token_pair` answers from a + * table keyed by contract id (so "no pool for this pair" is answerable), + * and `simulate_swap` answers the fixed response given. + */ +function fakePhoenix(opts: { + pool?: string | 'none' + askAmount?: bigint + totalReturn?: bigint + simulateError?: string +}): Sim { + return { + async simulateTransaction(tx) { + const fn = fnOf(tx) + if (fn === 'query_for_pool_by_token_pair') { + if (opts.pool === 'none') return errorResponse('LiquidityPoolNotFound') + return response(new Address(opts.pool ?? POOL).toScVal()) + } + if (fn === 'simulate_swap') { + if (opts.simulateError !== undefined) return errorResponse(opts.simulateError) + return response( + nativeToScVal( + { + ask_amount: opts.askAmount ?? BigInt(167408495), + commission_amount: BigInt(841248), + spread_amount: BigInt(1605936), + total_return: opts.totalReturn ?? BigInt(169855679), + }, + { + type: { + ask_amount: ['symbol', 'i128'], + commission_amount: ['symbol', 'i128'], + spread_amount: ['symbol', 'i128'], + total_return: ['symbol', 'i128'], + }, + } + ) + ) + } + return errorResponse(`unexpected call: ${fn}`) + }, + } +} + +describe('quoting Phoenix', () => { + it('resolves the pool, prices the swap, and carries the pool address', async () => { + const quoter = createPhoenixQuoter({ factoryId: FACTORY, serverImpl: fakePhoenix({}) }) + const out = await quoter.quote({ + kind: 'strict_send', + from: XLM, + to: USDC, + sendAmount: '10000000', + }) + + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.quote.source).toBe('phoenix') + expect(out.quote.poolAddress).toBe(POOL) + // ask_amount, not total_return — see the quoter's own header comment + // for why, confirmed live against a real swap. + expect(out.quote.destAmount).toBe('167408495') + expect(out.quote.path).toEqual([]) + }) + + it('reports no_route when the factory has no pool for the pair', async () => { + const quoter = createPhoenixQuoter({ + factoryId: FACTORY, + serverImpl: fakePhoenix({ pool: 'none' }), + }) + const out = await quoter.quote({ + kind: 'strict_send', + from: XLM, + to: USDC, + sendAmount: '10000000', + }) + + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.failure.reason).toBe('no_route') + }) + + it('reports upstream_error when the pool refuses to price the trade', async () => { + const quoter = createPhoenixQuoter({ + factoryId: FACTORY, + serverImpl: fakePhoenix({ simulateError: 'AssetNotInPool' }), + }) + const out = await quoter.quote({ + kind: 'strict_send', + from: XLM, + to: USDC, + sendAmount: '10000000', + }) + + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.failure.reason).toBe('upstream_error') + }) + + it('declines a fixed-output request rather than guessing', async () => { + const quoter = createPhoenixQuoter({ factoryId: FACTORY, serverImpl: fakePhoenix({}) }) + const out = await quoter.quote({ + kind: 'strict_receive', + from: XLM, + to: USDC, + receiveAmount: '10000000', + }) + + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.failure.reason).toBe('unsupported_pair') + }) + + it('is not configured without a factory id (e.g. on testnet)', () => { + // No factoryId override: falls back to phoenixFactory(), which reads + // the ambient test network — testnet by default, with no + // NEXT_PUBLIC_STELLAR_NETWORK stubbed here — and returns undefined + // there, since Phoenix has no testnet deployment. + const quoter = createPhoenixQuoter({ serverImpl: fakePhoenix({}) }) + expect(quoter.isConfigured()).toBe(false) + }) + + it('quoteAll agrees with quote on the same answer', async () => { + const quoter = createPhoenixQuoter({ factoryId: FACTORY, serverImpl: fakePhoenix({}) }) + const [all, best] = await Promise.all([ + quoter.quoteAll?.({ kind: 'strict_send', from: XLM, to: USDC, sendAmount: '10000000' }), + quoter.quote({ kind: 'strict_send', from: XLM, to: USDC, sendAmount: '10000000' }), + ]) + if (all?.ok !== true || !best.ok) throw new Error('no quotes') + expect(all.quotes).toHaveLength(1) + expect(all.quotes[0]?.destAmount).toBe(best.quote.destAmount) + }) +}) + +describe('the asset contracts phoenix is asked about', () => { + it('derives the same mainnet SAC ids this module was verified against', () => { + // Sanity check on the fixture itself: if these ever drift from what the + // live test actually exercised, this test — not a live-network call — + // is where that would be caught. + expect(sacFor(XLM)).toBeTypeOf('string') + expect(sacFor(USDC)).toBeTypeOf('string') + }) +}) diff --git a/apps/dapp/lib/__tests__/venue-integration.test.ts b/apps/dapp/lib/__tests__/venue-integration.test.ts index 9b57eaf6..7a08ab9a 100644 --- a/apps/dapp/lib/__tests__/venue-integration.test.ts +++ b/apps/dapp/lib/__tests__/venue-integration.test.ts @@ -76,12 +76,19 @@ describe('integration status is claimed accurately', () => { it('lists no Stellar venue the app cannot reach at all', () => { // A venue earns its place by being reachable — quotable at least, and - // ideally signable. Phoenix and Lumenswap were neither: Phoenix's testnet - // router resolves to a contract that does not exist, so a route through it - // would be priced and then fail at signing, and Lumenswap has been - // unmaintained since 2024 with an API that no longer answers. Listing - // either one offered the agents a venue no intent could ever use. - const gone = ['phoenix', 'lumenswap'] + // ideally signable. Lumenswap is neither: unmaintained since 2024, with + // an API that no longer answers (the site still renders prices, which is + // why it looks alive). Listing it offered the agents a venue no intent + // could ever use. + // + // Phoenix was dropped here once too (#37, 2026-09-23), for a different + // and narrower reason: it was listed only as a label the Soroswap + // aggregator's testnet route list could name, pointing at a testnet + // contract that did not exist. It is relisted now as its own direct, + // mainnet-only integration — a real factory and 13 live pools, verified + // live, with its own quoter and builder — which is a different claim + // than the one #37 found false. + const gone = ['lumenswap'] for (const id of gone) { expect(byId(id)).toBeUndefined() } diff --git a/apps/dapp/lib/__tests__/venue-networks.test.ts b/apps/dapp/lib/__tests__/venue-networks.test.ts index a89f6412..dc82a778 100644 --- a/apps/dapp/lib/__tests__/venue-networks.test.ts +++ b/apps/dapp/lib/__tests__/venue-networks.test.ts @@ -46,6 +46,7 @@ describe('which venues are on mainnet at launch', () => { 'etherfuse', 'horizon', 'linq', + 'phoenix', 'sorobandomains', 'soroswap', 'soroswap-aggregator', @@ -55,11 +56,12 @@ describe('which venues are on mainnet at launch', () => { ) }) - it('keeps every Stellar venue on testnet, except one that has no testnet', () => { - // Linq pays real naira against mainnet XLM and USDC. There is no testnet - // service behind it, so it is deliberately absent there rather than listed - // and unusable. - const mainnetOnly = new Set(['linq']) + it('keeps every Stellar venue on testnet, except the ones with no testnet', () => { + // Linq pays real naira against mainnet XLM and USDC, with no testnet + // service behind it. Phoenix has no standing testnet deployment at all + // (see contract-registry.ts's phoenixFactory). Both are deliberately + // absent there rather than listed and unusable. + const mainnetOnly = new Set(['linq', 'phoenix']) for (const v of stellar) { expect(isVenueOn(v, 'testnet'), `${v.id} on testnet`).toBe(!mainnetOnly.has(v.id)) } @@ -91,6 +93,7 @@ describe('what the agents are offered', () => { 'aquarius', 'blend', 'etherfuse', + 'phoenix', 'soroswap', 'soroswap-aggregator', 'stellar-pools', @@ -125,8 +128,10 @@ describe('nothing is built against a venue that is not here', () => { expect(() => assertVenueOn('etherfuse', 'mainnet')).not.toThrow() expect(() => assertVenueOn('noether', 'mainnet')).toThrow('Noether is not on mainnet yet') expect(() => assertVenueOn('defindex', 'mainnet')).toThrow('DeFindex is not on mainnet yet') + // Phoenix has no testnet deployment, the mirror of Noether above. + expect(() => assertVenueOn('phoenix', 'testnet')).toThrow('Phoenix is not on testnet yet') // An id nobody listed is not a venue at all, and is refused the same way. - expect(() => assertVenueOn('phoenix', 'testnet')).toThrow('phoenix is not on testnet yet') + expect(() => assertVenueOn('uniswap-v4', 'testnet')).toThrow('uniswap-v4 is not on testnet yet') }) }) @@ -212,9 +217,10 @@ describe('what the Apps page may call available, decided on the server', () => { expect(configured.has('testanchor')).toBe(false) }) - it('has every Stellar venue on testnet, except the one that has no testnet', () => { + it('has every Stellar venue on testnet, except the ones that have no testnet', () => { const ids = availableVenueIds('testnet', {}) - for (const v of stellar) expect(ids.has(v.id), v.id).toBe(v.id !== 'linq') + const mainnetOnly = new Set(['linq', 'phoenix']) + for (const v of stellar) expect(ids.has(v.id), v.id).toBe(!mainnetOnly.has(v.id)) }) it('labels by that decision, not by the static list alone', () => { diff --git a/apps/dapp/lib/__tests__/venue-routing.test.ts b/apps/dapp/lib/__tests__/venue-routing.test.ts index 3a945485..8a4b15d9 100644 --- a/apps/dapp/lib/__tests__/venue-routing.test.ts +++ b/apps/dapp/lib/__tests__/venue-routing.test.ts @@ -48,6 +48,7 @@ const horizonQuote = { } const soroswapQuote = { ...horizonQuote, source: 'soroswap' as const, destAmount: '4737844000' } +const phoenixQuote = { ...horizonQuote, source: 'phoenix' as const, destAmount: '4700000000' } describe('the venue on the quote picks the builder', () => { it('sends a Horizon route to the classic path-payment builder', () => { @@ -60,6 +61,14 @@ describe('the venue on the quote picks the builder', () => { expect(builderFor(soroswapQuote)).toBe('soroban') }) + it('sends a Phoenix route to its own builder, not the Aquarius one', () => { + // Shares no shape with either: no router argument to pin, no u128 pool + // index. Folding it into another venue's builder would sign against + // whichever contract that builder assumes rather than the pool this + // quote actually priced. + expect(builderFor(phoenixQuote)).toBe('phoenix') + }) + it('sends an aggregator route to the aggregator builder, not the Soroban one', () => { // Same vendor, different contract and different argument layout. The // Soroban builder asserts a router call with the recipient fourth; the @@ -166,6 +175,24 @@ function aggregatorCall(opts: { to?: string } = {}): string { ) } +const PHOENIX_POOL = 'CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX' + +/** A Phoenix pool `swap`; the sender sits first, same as Aquarius but a different seven. */ +function phoenixCall(opts: { sender?: string; fn?: string; pool?: string } = {}): string { + return envelope( + new Contract(opts.pool ?? PHOENIX_POOL).call( + opts.fn ?? 'swap', + new Address(opts.sender ?? ME).toScVal(), + new Address(sacFor(XLM)).toScVal(), + nativeToScVal(BigInt('100000000'), { type: 'i128' }), + nativeToScVal(BigInt('1'), { type: 'i128' }), + xdr.ScVal.scvVoid(), + xdr.ScVal.scvVoid(), + xdr.ScVal.scvVoid() + ) + ) +} + function pathPayment(destination: string): string { return envelope( Operation.pathPaymentStrictSend({ @@ -240,6 +267,29 @@ describe('a signed envelope is re-asserted by the shape it actually has', () => ).toThrow(/not a swap contract/) }) + it('accepts a Phoenix swap, named "swap", with no static id to match', () => { + // No router id this switch can key on — Phoenix's call target is one of + // 13-and-growing pool addresses — so this reaches the default branch and + // the function-name fallback, not a case label. + expect(() => assertSelfSubmission(phoenixCall(), ME)).not.toThrow() + }) + + it('refuses a Phoenix swap that pays somebody else', () => { + expect(() => assertSelfSubmission(phoenixCall({ sender: STRANGER }), ME)).toThrow( + /pays .* not the signing account/ + ) + }) + + it('refuses a Phoenix-pool call that is not named swap, same as any other unknown contract', () => { + // withdraw_liquidity on the same pool contract is not a swap, and there + // is no router to say so by id — the function-name check is what + // catches it, and a contract call named anything else falls through to + // the ordinary "not a swap contract" refusal. + expect(() => assertSelfSubmission(phoenixCall({ fn: 'withdraw_liquidity' }), ME)).toThrow( + /not a swap contract/ + ) + }) + it('still accepts a path payment back to the sender', () => { expect(() => assertSelfSubmission(pathPayment(ME), ME)).not.toThrow() }) diff --git a/apps/dapp/lib/agents/market-context.ts b/apps/dapp/lib/agents/market-context.ts index 4b4381da..4c041593 100644 --- a/apps/dapp/lib/agents/market-context.ts +++ b/apps/dapp/lib/agents/market-context.ts @@ -11,6 +11,7 @@ import { fromBaseUnits, resolveAsset } from '../swap/assets' import { activeSources, type QuoteSource, type SwapQuote } from '../swap/quote' import { createHorizonQuoter } from '../swap/sources/horizon-quoter' import { createAquariusQuoter } from '../swap/sources/aquarius-quoter' +import { createPhoenixQuoter } from '../swap/sources/phoenix-quoter' import { createSoroswapQuoter } from '../swap/sources/soroswap-quoter' import { fetchFxPrices } from '../prices/reflector' import { createSoroswapAggregatorQuoter } from '../swap/sources/soroswap-aggregator-quoter' @@ -80,6 +81,7 @@ export function defaultQuoteSources(): QuoteSource[] { createAquariusQuoter(), createSoroswapQuoter(), createSoroswapAggregatorQuoter(), + createPhoenixQuoter(), ] } diff --git a/apps/dapp/lib/swap/build-phoenix.ts b/apps/dapp/lib/swap/build-phoenix.ts new file mode 100644 index 00000000..c00f85b4 --- /dev/null +++ b/apps/dapp/lib/swap/build-phoenix.ts @@ -0,0 +1,269 @@ +import { stellarNetwork } from '@intent/config' +import { + Account, + Address, + BASE_FEE, + Contract, + FeeBumpTransaction, + TransactionBuilder, + nativeToScVal, + xdr, +} from '@stellar/stellar-sdk' + +import { resolveVerifiedAsset } from './asset-registry' +import type { ClassicAsset } from './assets' +import { sacFor } from './build-soroban' +import { assertVenueOn } from '../venues' + +/** + * Swapping through one Phoenix pool, called directly by its contract id. + * + * Phoenix has no router to call: a swap is one `invokeHostFunction` against + * the pool contract the quote already resolved, not a router with the pool + * as an argument. So where `build-aquarius.ts` asserts a pool *index* + * argument matches the quote, this asserts the call *target itself* is the + * quoted pool — the same discipline, carried by the one piece of Phoenix + * that is actually fixed per swap. + * + * **The recipient is `sender`, and it is asserted.** Phoenix's pool `swap` + * pays the ask asset to whatever address is passed as `sender` — the same + * recipient-pinning concern as every builder here, since the value is read + * back out of the signed bytes rather than trusted from the inputs. + * + * **Argument order is `sender, offer_asset, offer_amount, + * ask_asset_min_amount, max_spread_bps, deadline, max_allowed_fee_bps`** — + * seven arguments, read from the live Rust source + * (`contracts/pool/src/contract.rs`, `PoolTrait::swap`) on 2026-10-28, not + * assumed from Aquarius's own seven-argument `swap`, which is a different, + * unrelated seven. + */ + +const TIMEOUT_SECONDS = 180 + +export interface BuildPhoenixSwapOptions { + /** The account spending, and the only account that may receive. */ + account: string + from: ClassicAsset + to: ClassicAsset + /** Base units (stroops). A string, never a number. */ + sendAmount: string + /** The floor the pool must clear, in base units. */ + minReceive: string + /** The exact pool contract the quote priced. */ + poolAddress: string + horizonUrl?: string + fetchImpl?: typeof fetch +} + +export interface BuiltPhoenixSwap { + xdr: string + recipient: string + minReceive: string + sendAmount: string + poolAddress: string + networkPassphrase: string +} + +async function loadSequence( + account: string, + horizonUrl: string, + fetchImpl: typeof fetch +): Promise { + const res = await fetchImpl(`${horizonUrl}/accounts/${account}`, { + headers: { Accept: 'application/json' }, + }) + if (res.status === 404) { + throw new Error('This account is not funded yet, so it cannot swap.') + } + if (!res.ok) throw new Error(`Horizon ${res.status}`) + + const body = (await res.json()) as { sequence?: string } + if (body.sequence === undefined) throw new Error('Horizon returned no sequence number') + return body.sequence +} + +export async function buildPhoenixSwap( + options: BuildPhoenixSwapOptions +): Promise { + assertVenueOn('phoenix') + const { account, from, to, sendAmount, minReceive, poolAddress } = options + const horizonUrl = options.horizonUrl ?? stellarNetwork.horizonUrl + const fetchImpl = options.fetchImpl ?? fetch + + if (BigInt(sendAmount) <= BigInt(0)) { + throw new Error('a swap needs a positive amount') + } + if (BigInt(minReceive) < BigInt(0)) { + throw new Error('a minimum received must be positive') + } + if (poolAddress === '') { + throw new Error('a Phoenix swap needs the pool address its quote came from') + } + + // The allowlist is the real check on the assets. A SAC id can be derived + // for any well-formed asset, so deriving one proves nothing about whether + // it should be traded. + for (const asset of [from, to]) { + if (resolveVerifiedAsset(asset.code) === undefined) { + throw new Error(`no Soroban contract for ${asset.code}: it is not a verified asset`) + } + } + + let fromContract: string + try { + fromContract = sacFor(from) + sacFor(to) + } catch { + throw new Error(`no Soroban contract for ${from.code} or ${to.code}`) + } + + const sequence = await loadSequence(account, horizonUrl, fetchImpl) + + // Stated once, here, and asserted below. + const recipient = account + + const operation = new Contract(poolAddress).call( + 'swap', + new Address(recipient).toScVal(), + new Address(fromContract).toScVal(), + // i128, not u128 — Phoenix's pool interface, unlike Aquarius's router. + nativeToScVal(BigInt(sendAmount), { type: 'i128' }), + nativeToScVal(BigInt(minReceive), { type: 'i128' }), // ask_asset_min_amount: Option + xdr.ScVal.scvVoid(), // max_spread_bps: Option, None + xdr.ScVal.scvVoid(), // deadline: Option, None + xdr.ScVal.scvVoid() // max_allowed_fee_bps: Option, None + ) + + const tx = new TransactionBuilder(new Account(account, sequence), { + fee: BASE_FEE, + networkPassphrase: stellarNetwork.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(TIMEOUT_SECONDS) + .build() + + const built = tx.toXDR() + assertSelfPhoenixSwap(built, account, poolAddress) + + return { + xdr: built, + recipient, + minReceive, + sendAmount, + poolAddress, + networkPassphrase: stellarNetwork.networkPassphrase, + } +} + +/** + * Re-reads the built transaction and refuses anything that is not a lone + * Phoenix pool `swap` against the quoted pool, paying this account. + * + * Three checks, because Phoenix has three places a substitution could + * hide: the call target (any of the 13-and-growing pools, or anything + * else entirely — there is no router to narrow this to), the function + * (`withdraw_liquidity` and `provide_liquidity` share the contract and + * are not swaps), and the recipient (the pool pays `sender`). + * + * `poolAddress` omitted re-runs the function and recipient checks without + * pinning the call target — `assertShapeOfPhoenixSwap` below is this with + * that argument always absent, for the one caller that genuinely has no + * quote to compare against: `venue-routing.ts`'s post-signature re-check, + * which re-validates a signed envelope with only the account to go on, no + * static router id to match (Phoenix has none) and no live call to make + * (that check is synchronous everywhere it is called). This matches, not + * loosens, what already ships for Aquarius: `assertSelfAquariusSwap`'s own + * re-check never compares its pool index against a specific expected value + * either — pinning to the exact quoted pool happens once, here, at build + * time, and the re-check's job is catching a tampered shape, not proving + * pool identity a second time. + */ +export function assertSelfPhoenixSwap(built: string, account: string, poolAddress?: string): void { + const decoded = TransactionBuilder.fromXDR(built, stellarNetwork.networkPassphrase) + + if (decoded instanceof FeeBumpTransaction) { + throw new Error('fee-bump transactions are not supported here') + } + const tx = decoded + + if (tx.source !== account) { + throw new Error(`refusing to sign: transaction source ${tx.source} is not the account`) + } + + if (tx.operations.length !== 1) { + throw new Error(`expected exactly one operation, found ${tx.operations.length}`) + } + + const op = tx.operations[0] + if (op === undefined) throw new Error('transaction has no operation') + if (op.type !== 'invokeHostFunction') { + throw new Error(`operation ${op.type} is not a contract invocation`) + } + + const hostFunction = ( + op as unknown as { + func?: { + type?: string + invokeContract?: { + contractAddress?: xdr.ScAddress + functionName?: unknown + args?: unknown[] + } + } + } + ).func + if (hostFunction?.type !== 'hostFunctionTypeInvokeContract') { + throw new Error('refusing to sign: this is not a contract invocation') + } + + const invocation = hostFunction.invokeContract + if (invocation === undefined) throw new Error('refusing to sign: the call could not be read') + + if (poolAddress !== undefined) { + // `contractAddress` is a raw ScAddress, the invoked contract's own + // address — not an ScVal argument, so Address.fromScVal (which reads an + // argument out of a scvAddress wrapper) throws on it. Matches + // plan-validator.ts's readContractCall, which hits this exact value. + const calledContract = + invocation.contractAddress !== undefined + ? Address.fromScAddress(invocation.contractAddress).toString() + : undefined + if (calledContract !== poolAddress) { + throw new Error( + `refusing to sign: this calls ${calledContract ?? 'an unreadable contract'}, not the ` + + `pool ${poolAddress} this quote priced. A different pool is a different trade.` + ) + } + } + + if (String(invocation.functionName) !== 'swap') { + throw new Error( + `refusing to sign: ${String(invocation.functionName)} is not a Phoenix swap. ` + + 'Only swap may be built here.' + ) + } + + const args = (invocation.args ?? []) as Parameters[0][] + if (args.length !== 7) { + throw new Error(`refusing to sign: a Phoenix swap takes seven arguments, found ${args.length}`) + } + + const sender = args[0] + if (sender === undefined) throw new Error('refusing to sign: the swap names no recipient') + const recipient = Address.fromScVal(sender).toString() + if (recipient !== account) { + throw new Error( + `refusing to sign: the swap pays ${recipient}, not the signing account. ` + + 'A Phoenix swap must pay the account that funds it.' + ) + } +} + +/** + * The shape-only form of `assertSelfPhoenixSwap`, for `venue-routing.ts`'s + * post-signature re-check — see that function's own doc comment for why + * this does not, and should not, compare against a specific pool address. + */ +export function assertShapeOfPhoenixSwap(built: string, account: string): void { + assertSelfPhoenixSwap(built, account) +} diff --git a/apps/dapp/lib/swap/contract-registry.ts b/apps/dapp/lib/swap/contract-registry.ts index 3c0e69eb..7209f9ec 100644 --- a/apps/dapp/lib/swap/contract-registry.ts +++ b/apps/dapp/lib/swap/contract-registry.ts @@ -152,6 +152,45 @@ export const noetherRouter = lazyId({ mainnet: undefined, }) +/** + * Phoenix's factory: looks pools up, holds no liquidity itself, and is not + * the contract a swap signs against — a pool is. Registered here only for + * review's sake, since the quoter calls it read-only to resolve the pool + * address, which the signed transaction never touches. + * + * No `lazyId` for the pool itself: Phoenix addresses pools by their own + * contract id, not an index into one router the way Aquarius does, and the + * set is runtime-discovered and already larger than any snapshot of it (13 + * live pools on 2026-10-28, two more than Phoenix's own last-touched + * mainnet upgrade script names). A static allowlist entry per pool would + * drift the moment Phoenix adds one. The builder instead pins to the exact + * pool address its quote named — the same discipline `poolIndex` already + * enforces for Aquarius, adapted to Phoenix's addressing — so nothing here + * needs to enumerate pools for a swap to be checked. + * + * Mainnet address verified live on 2026-10-28 by simulating + * `get_config()` and cross-checking its returned `multihop_address` + * against github.com/Phoenix-Protocol-Group/phoenix-contracts's + * `scripts/upgrade_mainnet.sh`, which names both addresses — they + * corroborate each other independently of the script alone. + * + * No testnet id: Phoenix's deploy scripts stand up fresh contracts rather + * than referencing one standing testnet instance, and no testnet + * factory constant appears anywhere in the repository. Mainnet-only, + * the mirror of Noether's testnet-only case above. + */ +const PHOENIX_FACTORY_MAINNET = 'CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI' + +/** + * Not `lazyId`: that helper's `testnet` id is required, which fits every + * other venue here but not Phoenix, which has no testnet id to give. Written + * directly instead of changing `lazyId`'s contract for one venue that is the + * exception rather than the rule. + */ +export function phoenixFactory(network: StellarNetworkName = activeNetwork()): string | undefined { + return network === 'mainnet' ? PHOENIX_FACTORY_MAINNET : undefined +} + /** * Where to see a Blend position, as opposed to the transaction that made it. * diff --git a/apps/dapp/lib/swap/quote.ts b/apps/dapp/lib/swap/quote.ts index 3f4a1517..35a320ed 100644 --- a/apps/dapp/lib/swap/quote.ts +++ b/apps/dapp/lib/swap/quote.ts @@ -20,7 +20,7 @@ import { venueIdOn } from '../venues' * two apart — and the direct quoters stay in the line-up beside it rather * than being replaced by one vendor's view of them. */ -export type QuoteSourceId = 'horizon' | 'soroswap' | 'aquarius' | 'soroswap-aggregator' +export type QuoteSourceId = 'horizon' | 'soroswap' | 'aquarius' | 'soroswap-aggregator' | 'phoenix' /** How each source is named where a person reads it. */ const SOURCE_DISPLAY_NAMES: Record = { @@ -28,6 +28,7 @@ const SOURCE_DISPLAY_NAMES: Record = { soroswap: 'Soroswap', aquarius: 'Aquarius', 'soroswap-aggregator': 'Soroswap Aggregator', + phoenix: 'Phoenix', } /** A source's display name, or the raw id for one this build does not know. */ @@ -99,6 +100,16 @@ export interface SwapQuote { * pair, and the Aquarius builder refuses a quote without it. */ poolIndex?: string + /** + * Which Phoenix pool this quote came from, as a contract address. + * + * Phoenix addresses pools by their own contract id rather than an index + * into one router — there is no router-held "pool 2 of 3" concept the way + * there is for Aquarius — so `poolIndex`'s hex-32-byte shape does not fit + * here. Same purpose as `poolIndex`: the builder refuses to sign a swap + * against any pool but the one this quote actually priced. + */ + poolAddress?: string /** * How an aggregator splits the fill across venues, when it does. * @@ -198,6 +209,7 @@ const VENUE_OF_SOURCE: Record = { soroswap: 'soroswap', aquarius: 'aquarius', 'soroswap-aggregator': 'soroswap-aggregator', + phoenix: 'phoenix', } /** diff --git a/apps/dapp/lib/swap/sources/phoenix-quoter.ts b/apps/dapp/lib/swap/sources/phoenix-quoter.ts new file mode 100644 index 00000000..3c3466ea --- /dev/null +++ b/apps/dapp/lib/swap/sources/phoenix-quoter.ts @@ -0,0 +1,239 @@ +import { stellarNetwork } from '@intent/config' +import { + Account, + Address, + BASE_FEE, + Contract, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from '@stellar/stellar-sdk' + +import type { ClassicAsset } from '../assets' +import { sacFor } from '../build-soroban' +import { phoenixFactory } from '../contract-registry' +import type { + MultiQuoteOutcome, + QuoteFailure, + QuoteOutcome, + QuoteRequest, + QuoteSource, + SwapQuote, +} from '../quote' + +/** + * Quotes against Phoenix, a third Soroban AMM, mainnet only. + * + * Structurally different from Aquarius and Soroswap: there is no single + * router. A **factory** contract looks pools up by token pair; each pair's + * liquidity lives in its own **pool** contract, addressed by its own + * contract id rather than an index into a router. A direct quote is + * therefore two calls — ask the factory for the pool, then ask that pool to + * price the trade — not one call against a known router id. + * + * Everything below was verified live against mainnet Soroban RPC on + * 2026-10-28, by simulating each call named, not read from the contracts' + * Rust source alone: + * + * - **The factory and pool addresses are real and corroborate each other.** + * `factory.get_config()` returned a `multihop_address` matching + * Phoenix's own `scripts/upgrade_mainnet.sh` byte for byte — two + * independent sources agreeing is stronger evidence than either alone. + * - **`query_for_pool_by_token_pair` does not care about argument order.** + * Called with (XLM, USDC) and (USDC, XLM) and got back the identical pool + * address both times. Unlike Aquarius, there is no pair-sorting step here + * — porting Aquarius's `sortedPair` to Phoenix would be dead code. + * - **Amounts are `i128`**, matching Soroswap, not Aquarius's `u128`. + * - **`simulate_swap`'s `ask_amount` is the net amount a trade actually + * delivers; `total_return` is not.** For a live 100 XLM → USDC simulation, + * `ask_amount + commission_amount + spread_amount === total_return` + * exactly (167408485 + 841248 + 1605936 = 169855679, confirmed again on + * the reverse-direction swap with different numbers) — so `total_return` + * is the pre-fee, idealised figure and `ask_amount` is the real one. Using + * `total_return` as `destAmount` would overstate what a user receives by + * the fee and the spread. This is the field this quoter uses; getting it + * backwards here would be a real, money-shaped bug. + * + * Not verified live, and out of scope here: the **multihop** contract (a + * thin orchestrator over several pools, for routes with no direct pair) — + * a throwaway encoding attempt failed and was not pursued, since a direct + * quoter needs nothing from it. Add it only once its own `simulate_swap` + * has been exercised against a live call, not just read from source. + */ + +const SIMULATION_SOURCE = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' + +export interface PhoenixQuoterOptions { + rpcUrl?: string + factoryId?: string + /** Set false to disable the source without removing it from the list. */ + enabled?: boolean + /** Injected in tests, so pool handling is checkable without a network. */ + serverImpl?: Pick +} + +/** + * `{ ok: false, failure }` is the same shape in `QuoteOutcome` and + * `MultiQuoteOutcome`, so this is usable from either — annotated with + * neither, so it structurally fits both rather than being pinned to one. + */ +function failure( + reason: QuoteFailure['reason'], + detail?: string +): { ok: false; failure: QuoteFailure } { + return { ok: false, failure: { source: 'phoenix', reason, ...(detail ? { detail } : {}) } } +} + +export function createPhoenixQuoter(options: PhoenixQuoterOptions = {}): QuoteSource { + const rpcUrl = options.rpcUrl ?? stellarNetwork.sorobanRpcUrl + const factoryId = options.factoryId ?? phoenixFactory() + const enabled = options.enabled ?? true + + function server(): Pick { + return options.serverImpl ?? new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }) + } + + async function simulate(contractId: string, fn: string, args: xdr.ScVal[]): Promise { + const tx = new TransactionBuilder(new Account(SIMULATION_SOURCE, '0'), { + fee: BASE_FEE, + networkPassphrase: stellarNetwork.networkPassphrase, + }) + .addOperation(new Contract(contractId).call(fn, ...args)) + .setTimeout(30) + .build() + + const sim = await server().simulateTransaction(tx) + if (rpc.Api.isSimulationError(sim)) throw new Error(sim.error) + if (sim.result === undefined) throw new Error(`${fn} returned nothing`) + return scValToNative(sim.result.retval) + } + + function contractFor(asset: ClassicAsset): string | undefined { + try { + return sacFor(asset) + } catch { + return undefined + } + } + + async function quoteAll(req: QuoteRequest): Promise { + // Fixed-input only, matching Aquarius and Soroswap: nothing here has + // exercised Phoenix's reverse-swap path, and quoting a shape never run + // live would be worse than declining. + if (req.kind !== 'strict_send') { + return { ok: false, failure: { source: 'phoenix', reason: 'unsupported_pair' } } + } + if (factoryId === undefined) { + // Mainnet only; the caller should not have asked on testnet, but a + // defensive no_route is cheaper than an exception. + return { ok: false, failure: { source: 'phoenix', reason: 'unsupported_pair' } } + } + + const fromContract = contractFor(req.from) + const toContract = contractFor(req.to) + if (fromContract === undefined || toContract === undefined) { + return { ok: false, failure: { source: 'phoenix', reason: 'unsupported_pair' } } + } + + let poolAddress: string + try { + // Order genuinely does not matter here — verified live both ways — + // unlike Aquarius, so the two contracts are passed as asked. + const pool = await simulate(factoryId, 'query_for_pool_by_token_pair', [ + new Address(fromContract).toScVal(), + new Address(toContract).toScVal(), + ]) + if (typeof pool !== 'string' || pool === '') throw new Error('factory named no pool') + poolAddress = pool + } catch (e) { + // No pool for the pair is an ordinary answer, not a fault. + return { + ok: false, + failure: { + source: 'phoenix', + reason: 'no_route', + ...(e instanceof Error ? { detail: e.message } : {}), + }, + } + } + + let response: unknown + try { + response = await simulate(poolAddress, 'simulate_swap', [ + new Address(fromContract).toScVal(), + // i128, not u128 — Phoenix's pool interface, unlike Aquarius's router. + nativeToScVal(BigInt(req.sendAmount), { type: 'i128' }), + ]) + } catch (e) { + return failure('upstream_error', e instanceof Error ? e.message : undefined) + } + + const askAmount = + response !== null && typeof response === 'object' && 'ask_amount' in response + ? (response as { ask_amount: unknown }).ask_amount + : undefined + + let dest: bigint + try { + dest = BigInt(String(askAmount)) + } catch { + return failure('upstream_error', 'the pool returned no ask_amount') + } + if (dest <= BigInt(0)) return failure('no_route') + + const quote: SwapQuote = { + source: 'phoenix', + kind: 'strict_send', + from: req.from, + to: req.to, + sendAmount: req.sendAmount, + destAmount: dest.toString(), + // A single pool is a direct swap; there are no hops to replay. + path: [], + // The exact pool this price came from. The builder signs only this + // address, the same discipline poolIndex enforces for Aquarius. + poolAddress, + quotedAt: new Date().toISOString(), + } + return { ok: true, quotes: [quote] } + } + + return { + id: 'phoenix', + displayName: 'Phoenix', + isConfigured: () => enabled && factoryId !== undefined && rpcUrl !== '', + + async quote(req: QuoteRequest): Promise { + try { + const all = await quoteAll(req) + if (!all.ok) return { ok: false, failure: all.failure } + const best = all.quotes[0] + return best === undefined ? failure('no_route') : { ok: true, quote: best } + } catch (e) { + const aborted = e instanceof Error && e.name === 'AbortError' + return failure( + aborted ? 'timeout' : 'upstream_error', + e instanceof Error ? e.message : undefined + ) + } + }, + + async quoteAll(req: QuoteRequest): Promise { + try { + return await quoteAll(req) + } catch (e) { + const aborted = e instanceof Error && e.name === 'AbortError' + return { + ok: false, + failure: { + source: 'phoenix', + reason: aborted ? 'timeout' : 'upstream_error', + ...(e instanceof Error ? { detail: e.message } : {}), + }, + } + } + }, + } +} diff --git a/apps/dapp/lib/swap/venue-routing.ts b/apps/dapp/lib/swap/venue-routing.ts index 81ac33d9..45c96066 100644 --- a/apps/dapp/lib/swap/venue-routing.ts +++ b/apps/dapp/lib/swap/venue-routing.ts @@ -7,6 +7,7 @@ import { assertSelfSwap } from './build-tx' import { aquariusRouter, soroswapAggregator, soroswapRouter } from './contract-registry' import { readContractCall } from './plan-validator' import type { SwapQuote } from './quote' +import { assertShapeOfPhoenixSwap } from './build-phoenix' /** * Deciding which builder signs a given quote, and which assertion re-checks @@ -47,7 +48,7 @@ import type { SwapQuote } from './quote' * — the recipient sits sixth on the aggregator contract, fourth on the * router, and in `destination` on a classic path payment. */ -export type VenueKind = 'classic' | 'soroban' | 'aquarius' | 'aggregator' +export type VenueKind = 'classic' | 'soroban' | 'aquarius' | 'aggregator' | 'phoenix' /** * Which builder a quote must go to. @@ -76,6 +77,8 @@ export function builderFor(quote: SwapQuote): VenueKind { return 'aquarius' case 'soroswap-aggregator': return 'aggregator' + case 'phoenix': + return 'phoenix' default: throw new Error(`no builder for quotes from ${String(quote.source)}`) } @@ -113,7 +116,7 @@ export function assertSelfSubmission(signedXdr: string, account: string): void { return } - const { contractId } = readContractCall(op) + const { contractId, functionName } = readContractCall(op) switch (contractId) { case aquariusRouter(): assertSelfAquariusSwap(signedXdr, account) @@ -125,6 +128,19 @@ export function assertSelfSubmission(signedXdr: string, account: string): void { assertSelfSoroswapSwap(signedXdr, account, 'aggregator') return default: + // No static id to match: Phoenix has no router, and the call target + // is one of 13-and-growing pool addresses with no allowlist entry — + // see contract-registry.ts's phoenixFactory comment. A call named + // "swap" against *some* contract is handed to the Phoenix shape + // check, which re-validates the function and recipient the same way + // every other venue's re-check does. Anything that fails that check + // (wrong argument count, wrong recipient, not actually named "swap") + // falls through to the same refusal every other unknown contract + // gets — this does not widen what is accepted, only what is tried. + if (functionName === 'swap') { + assertShapeOfPhoenixSwap(signedXdr, account) + return + } throw new Error( `refusing to submit: ${contractId ?? 'the contract called'} is not a swap contract this app builds calls to` ) diff --git a/apps/dapp/lib/venues.ts b/apps/dapp/lib/venues.ts index 7a4ec3af..aa528a3f 100644 --- a/apps/dapp/lib/venues.ts +++ b/apps/dapp/lib/venues.ts @@ -132,6 +132,23 @@ export const venues: Venue[] = [ capability: 'Swaps route through Aquarius’s router against the pool the agent chose. Which router wins depends on the direction of the trade.', }, + { + // Mainnet only: Phoenix has no standing testnet instance to quote + // against, the mirror of Noether being testnet-only above. Each pair + // has its own pool contract rather than one router, so the agent's + // chosen pool travels on the quote and the builder signs only that one. + id: 'phoenix', + name: 'Phoenix', + family: 'stellar' as const, + category: 'swap', + chains: ['Stellar'], + bestFor: 'A second independent AMM to compare against Aquarius and Soroswap', + url: 'https://phoenix-hub.io', + integration: 'executes', + networks: ['mainnet'], + capability: + 'Swaps route through the exact Phoenix pool the agent chose, resolved from Phoenix’s factory for the pair.', + }, { id: 'stellarx', name: 'StellarX', From f99114a8925cc888c1ae72044071a335a098dfd0 Mon Sep 17 00:00:00 2001 From: Depo-dev <24318346+Depo-dev@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:24:06 +0100 Subject: [PATCH 7/7] feat(dapp): add the phoenix and linq venue logos Cropped to square and resized to the 128x128 webp every other venue logo uses. Phoenix's source was a wide icon+wordmark lockup with the mark at the left edge, not centered, so a left-aligned crop was used rather than the usual center crop -- a center crop on this one would have cut the mark off and kept mostly blank space and text. Linq's source was already square. No code change needed: SourceLogo and VenueCard both build the path from the venue id, and linq was already a listed venue with no logo. --- apps/dapp/public/images/venues/linq.webp | Bin 0 -> 2436 bytes apps/dapp/public/images/venues/phoenix.webp | Bin 0 -> 5422 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/dapp/public/images/venues/linq.webp create mode 100644 apps/dapp/public/images/venues/phoenix.webp diff --git a/apps/dapp/public/images/venues/linq.webp b/apps/dapp/public/images/venues/linq.webp new file mode 100644 index 0000000000000000000000000000000000000000..53bce270dceff5016cd3be5a5cb097e991deaf5f GIT binary patch literal 2436 zcmV-~348WZNk&F|2><|BMM6+kP&goP2><{PDFB@TDu4ih06sAkheDzu486)a5CMW( z05pmOA0y_KRerPl@AC)lkNOUzbDis7#{Yx+jrckKJJ?^{-?&fdFZUnpJ!<^Qe=`4P z`)BUI(gFOV{y$jT-HQQV4>>0f&kvNmmDKa0~ zk2il*`vd-C`sMoS{o3|POg4toJa1QxINpQ?`G(hQceuZd|5>-hiRZ&<{#+_4N7S9! z0yuB!AuXFxmc&WAD@vsIlg>SkuZbO-94Uuo1QV_EMTW{LrPE~{59H^j>vG%?wpR;}I*(N#We{Ee#qQO7YFoHM*M10&HR{DCr}m>*`Tvp?DLk;U zFNygn8@AX(1%}B_zuXiOm^&xK+>|$n1@0pz4hcGo3Wksd&(8Rd|KiTOu37qC@oYi* zXu&dm@II9$a-AH_X8=#!8VzLdADmwCaJ0^y{X8(O7OupXw8>rTSbD+fd;bu3%V5(j zq}*j~lx`AFdc2-sHM{uO63;95>%Svpr%n2ot`@gZ!!Rmgj9Agd>(b`nbTibd`6HMS z^7=SHN*MB^e0>)L`KkPe_}U!`cy=jygx@>CpQcL4jvXny1ZL`fO&x3YOJ^J7DQIno zvgU?~1Bteqvr`>2c%w08Cc-YOh9*OUkEhUJVcLiUfASs#pAH$)HVw|5&XSGFx(`t1 zH3;$x&{`XM-$b z{i^timR(FII9o(4`rRd|8Sm@%wgHxER-Qs6Ay{5MoAy?c(l&(EFB6OuzS#S32L!B;MAw_o@!oJp3<+hhp&b}Bxv9E%PrZb9Q zU<$jF1)I~Cv}-OYW2X=Il_R6L`=;6-DE!y2a z=I+WUREquyVk`!<5;;(EI}2-&PevmdPxbt*2j0%(0mMa9pb2(7B-~$}%jeSI_ZlUH z-G?!^k6Z`;*G3ka|3Umt$*QoscJUn3wcAsz4>9)+7}6Mr4D?i&_*Bm8LjdNcK6XpX zb9p;c*Gi*k>T+Ma=ISk?hg|KA36F@Mr(nN~A8 zDrkFw3a{)gZ+W!dzg;S>#8kmG41jB%m1ZG*6w~*C1$O7$u#=qAzQ&#~0Cf;r!=3^t z;@t1wqVe7`Fm){t{pNHaD|*eZONHh1>(e<2_rACr=XBtO23~Q)<^}muy-HYW=BRU0 z`rBPq(8+t;TvYtSbhI@2*zGHj5l24kak4a7MuH<#ZPjn+E%qH$s+PZs9omsi zzdbEC#K}L{&>;&H3SnIV>K{?V$U31dU21~sZtu_wywU7@2GdYIJ6S9mTME_q5= z>}O|X@XJ`T(H7%t)gI8F#l3S4Qd!}TCs+;^j$Y;w+Pv|RV68x^xeVf~3ef%#|I_Ij zT!VO$CxJX$wy$0z+N;%9ubFDzk8)JmA%a%ycU%^7M~03=_uVHL-7tdWw)7h^bH8;O zAZRw!8qBx((!hdw&$Nuo#DC)PfCeWk3!vV6i(P{qy5L`HbF=tOy|B)aS!6t$yu8ZH z{v*|dxjxW0)iKSKZYY|}Xsjg0_;XJTi0F7cCCjQMqeKAj`7W+d7?N^ZB>1tdG}yfB@N!r9h_CesoW1lTdh7E( z*h$XwL>XYHpf01NnAkd0ADD;z9>l_uuxXpMV$M_f#=KNs)EQ(hLF+^>co+8v%-WCh zyB4?5u;mm2T#jDTh2%R5_mF}a8TXLNhuT$$3l5Y>T+)Dj~O}Dq~>9z3>Nd<$Z#ofOeBWfN1C4dC;tiV>9NVCW{i$3GaKS=_B0000G C!ORW- literal 0 HcmV?d00001 diff --git a/apps/dapp/public/images/venues/phoenix.webp b/apps/dapp/public/images/venues/phoenix.webp new file mode 100644 index 0000000000000000000000000000000000000000..369fc8db2b1ecc325b687210c827821b0780e57f GIT binary patch literal 5422 zcmV+}718QaNk&E{6#xKNMM6+kP&il$0000G0001g004gg06|PpNCXA|00E$f|NrT> z`kpkY+j+&5c4j-_jk_u{{D}M2>^*bhOgQ_ ztrh@)0062E+Y>SW#<=1FaMG@D{oi&SUD&0F3IIUkUVhZk?`V27)8CmFp0l6Fq@*Uj zKGf)6^=3cRrI}DP#$Id4hoa4Azs3T8%qk0-IxFAef93_c@W8RhN&N2z6E%JtzN3w& zdOFfqtkVU94cRx^%}+#IlXn-TukOkpXlusK7l@(__`0!%yk0krHd(Xs(e}i}e&qFk zWRr;hJ2z}7dF@YR*ur$B8+ATYlO(#{rx0=W^{(eh6TjPsIB=QzP#V;=pia=vN|PpY zu^({&jZY>^Vh#&XQ|IV^MH0l59@K$mTBtOyhj~y3Rc0hhl5(~QdEkFQF9l`{qtCJ9 ziF|e<;*RECE*jbZhDGEsL&T_~Lz-1{BChO&H&Wz%3%P&o$od)GD>~5yWsamvP;hSK z(dqpg`4o2~s_3jR$wYIu@#ot^6Z|y<6>2nM`OO5$3f4c_H+^U$X8;h$P`Q-ry6GLt zIW25si;@mNXW1ziW0Lb^WzEyyU)xhngkz%*4Jl05rLh&V#VuJ(#GR0ia`~g$GH8eVKp&Dz^Y5a`AOTHUJLI zb|D{s4s>SZGU+}ijEnslxvz`iMDB(DjEbE%al#xqgH0j75agtoyPi#r&XK%8JnY1z z<&ZD@xL!@eV&a@Qemv<^n8o^i$^3|zP@2Wz^XdHfx~wXT>kke5h~MtdBJ5o@KlJ2uoEY1Ua~R*9bEfy5IA^%ul0|Zw znRB|he181A+k!<*B1h6LH(~KCB8wmTgLPRPy_CU^s5RADY&w|2kB>7eu$aC|&yTQ? z-Yf|Q|L%0C-R?71h#JTUUMSn zR4+zOea~{jIJX)jb;YF?J|y*aWCSqc2N$fNHGz4D@EjgQZz;pB*4B6)+#XPbUD0mO zd9bUL9Xs&aZxX39>w`ejfCv+HX$MH22pvTQ$U^$ zjklU*R+Tgbo?e^8k$IfT&8*)rH`w1Xh0l;rz3^A6FQKdoN zmDlTNc^pt2WNue)m5vG#G5^IhACS2KP;Y%K5r`NPR2md$S9#%UM3IDRV?02C0)XG# zw^k%D<qGJPM}z|w%djbL=+Wd3YlKt0Te3$K#jwZ22`$DfAhDJ zpm2@%u=Baf`LM9AMx`_WuP$?LW+@~7(0D&JkSKJ&os?&lnP@V8+EW+Ebg4S}NUF?? z52v>)=ST-Yi7v}dKhkedB=sWMoqc@Q;`cy+!POiBC(UhBQNKa0T`VfA1>)2XuK)Tw=t&nH^J^?F_ zK8*Mfm0o})S@-VI`F%?#wyIFPkc&o83|3G$Ap8yh0B}11odGI<0Du5KF%*bGq9Gv` zOEvHy0|d4JY6%R#CH#Qc{_lQdRM+YMfB!3f41byI`;>pq`Q!N?=s)PcyngS0v-l0h}&@4w~$xPG&IfPXLkoBu!7 zE7)t$1NN8I!~IwOulD~3zq9}Mf69CY|33c%|9jX2Sy$+n$?xqykbksZxA>#--?u!^ zx=+n7S*Jr0A&I@MP7&C7Icu^^a@)Js?sssR3KQq6ZrD8qxGdHkQ^;=E}OTm2=rL{j7$219IhYbJlnfiOC zxm`%rRns9{2C*~l(As&^$-NtAUGD-*yopCFuwYjA61n^5*cF&W^T0AVh96Ui0092~ zkHug9kQ5ho^UifxE3H(=w1Z1|C&h>JUYPnmhm4tYIq0%`tM>zPt)7?Z*wA}UQO5bp zyYa&0%)TRK;OrDUw(|yDe}kMvS;J8RY6IOL{d>QZz5SAMe0!qX1iFWe5akp<4>Wq! z`fk#1d|c~*NVR=8wee%N|1uhZTB&C*j#aY?wBT}2EQXz;xgKwwFuRTl=P5A(`(g-a zgI$GhXx^)$Vbz&=Pwg?^$g_NZ!?}Os6NdbS2zE6gKg3{fTY!r2uJskpE5c8EEgG{h zL}e696ZS9Yhg<*Q(*Z|};p_jaR+R1K;7eBV8AAvk*y|tQ<_j8xIX>EmurPNhBAm;j%&8e!2r;lx7 z&x<^}^y(nj_!hARfJ-VY6aPLi_I2mbu+)aheXlp!Uu+g+G=;aGK%cRe%(893kP(3> zvAs78a1JYiw(Pw;+C<_jE3}QXvm>VW6)yb{N|~^Xc^JT7UEfL4bLGL1{HSd!+>$LaN@~ z>QzPoPm$1wZDpQzucg&XlNS3I#c)CnBlE7$nw6apaJj@1_w&QQ?~cN=&Ot1AAoii( zetE)b40jf7h(&U6z*&Y5L?8s@)7Dw(Br^ah1Ay&tWuKaGgSFgWMOyEdvtB5hNr_EW}jw(1By*<-)W zVEIQpF3SoYi!VDdEb+Aiq*&68B@UqOOL^kikG^|u*_wG}#rngvFAJ?@I#>CD^?_gE zsV)YA#yhd{cS{z(?>Bq;(~(}P3}CnW zEXHbOPtW7~M|f-P!xkY&OkC zox(efk}_WymL@$ZNa9L*RcUCvFZNgr0}91hx87VD%LCScL`Zx=2q;5XtJ^2wI^M@4JzR5qjEmgjMjUeQl66VZb6tFKLPn-LOy{d zQ%VgR7~_lM3oZp6>;b+)CyUJ{HLC%1_bkbCAfLEZ_E4KCK`&^1V}dg$k-+%J^U6eD zk49CwWPL?SYxPL38OKl*h75uftj(?q!T(kGV4%mP|KXBeeXO^OCbC&JGs>!o$a>9Q zC7oR?Ra#QwPch@|MT&FF^BI=aY35LF%8#N}dW5Rhl;Tqh_G+Z!(E_qWUFC~EiWoKc zJmP#Oq^J=L|wTE7Z+-rK{~@N%yr2{sk%57 zGpYN2s;z8}Cp(Xe1)FL1D{UpgCXa-`Zp2KcuoVctgce#Odr>WyeP1Y3YL0{|Ub3yD zHa~3*vW?yGYQE*NUyTDLd1uZaD$>J>MngO>46 z%;&ZMyT?-FI;(K*m)f5&MMZlheHcvS?01kE2o97e9*OGAfxCosC^pF@eAHMpl>qRI zRH_Avf}P;yGRZK8shtTk9F@7sbYYsxY=uNq7Ia>lICiN99dF)zw`XJvS7fKI70Zb- zG`7GBrIomumT16cPw+2!_2aAuyY|>?yfBZB1Z`ue8MCR9kE1JLt=hpIcQ`sZ)u_%Y zCVjB9lRH>lv{g(J5D)HC3$C9_CZ&DEC4kH1P0e+g6^XMc@<+DxoDD#;(@<^%tOUL_-`%PiOv@OQt>+Zd89Rp2rh&^ql>Yd}9d^q~QK7m2*gfZ{yBv zIe^oN?CT{G{*0Kb`B^INbat|e!(ku!;%XGlh=F1z5gEyuR#2%!3C=xIeR!Rl&fJ$|E3R_n`8HQ)+ zfSTaV2&Jg1`CO#>s3ogZaTSIWaYeXZrw3vEk2gbMc2HB_SY@{umC#8W(aUAYYQjUx>0UFD&{1?DwVHt)F(p(z-4j+EnLq^-k zy52kN#@EXw0&vsINlT9}OnM)DU$9p$mUXeO-KiU$CyP2QzdF@i07;{h&~5d<5%esc#0+C{VRO(5Kd23hGNc4*aP1hO&3Re(%$McxnM0gPEs5|= zpMk<^2y%KH4VyfuITGuJrQGfi&#PGlw9)oxtB2{=gN|j)NNUIY(1uOXJ{}93?=d3% zX9Kjjo6*YCxh-w`0`@EXQ(PMI;V)k&7Gx! z^rBVMA%!qsy}ouFJ1O=-Pp3O}fD-epO-Q~y{=d-nGmewqo#5DK-1&^UI0$lw2%>;~#nA*#n#~c<~ zym18H(^NDd2iSe+ABS!4*qq8Z(}`bxKVv>OZ!2osXk~Nsv)Y%p{I}S}N{qjemsR7M zMX0$f_g1jg{3n%f(Kzk$g5j+l2#FOW^e7ag&qk*V5H03xf8_)TTLsYA$ex{>99rke zNpVfX92WvKPfLBwac7r=_mZQ&zk&8*_&qCje`c(!y>+@q+aW)34nl;&TEc%i@k4tM|n00&5{k^lez literal 0 HcmV?d00001