diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 92a45075..26f715b3 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -69,6 +69,8 @@ MONEYGRAM_PRODUCTION_HOME_DOMAIN= # the transaction pooler, port 6543); localhost only works on your own machine. DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent # Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Also signs the 1Click deposits issued on Solana, Sui, Avalanche and Monad. Without it that route +# isn't offered, which leaves Avalanche and Monad with no swaps at all. AUTH_SECRET= # on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only # ACCESS_GATE=on turns it on, on any network. The API is outside @@ -209,5 +211,7 @@ WEAVE_SECRET_KEY= WEAVE_WEBHOOK_SECRET= # Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others. WEAVE_CASHOUT_CURRENCIES= -# Avalanche C-Chain RPC used to build and confirm USDC deposits. +# Avalanche C-Chain RPC used to build and confirm USDC deposits, and to confirm 1Click swaps there. AVALANCHE_RPC_URL= +# Monad RPC used to read balances and confirm 1Click swaps. Unset, viem's default public endpoint is used. +MONAD_RPC_URL= diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 57bf8348..534d6017 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -2,7 +2,12 @@ import { activeNetwork } from '@intent/config' import { NextResponse } from 'next/server' import { randomUUID } from 'node:crypto' -import type { AgentProposalResult } from '../../../../lib/agents/brain' +import type { AgentProposalResult, MarketContext } from '../../../../lib/agents/brain' +import { + intentFromChainAction, + quoteChainRoutes, + type ChainRouteQuote, +} from '../../../../lib/agents/chain-routes' import type { CompetitionFrame } from '../../../../lib/agents/events' import { encodeFrame } from '../../../../lib/agents/events' import { buildMarketContextAsync, quoteRoutes } from '../../../../lib/agents/market-context' @@ -11,6 +16,14 @@ import { benchedUntilFor, lineUp, noteRateLimited } from '../../../../lib/agents import { getRoster } from '../../../../lib/agents/registry' import type { PlanContext } from '../../../../lib/agents/plan-scoring' import { pickWinner, scoreProposals, unanimousChoice } from '../../../../lib/agents/scoring' +import { assertAmountWithinCap, assertKnownTokens } from '../../../../lib/chains/prepare' +import { chainModule } from '../../../../lib/chains/registry' +import { + ChainActionRefused, + type ChainActionKind, + type ChainActionRequest, + type ChainModule, +} from '../../../../lib/chains/types' import { parseCompoundIntent } from '../../../../lib/parse-compound' import { isLimitType } from '../../../../lib/intent-kind' import { buildParsedIntent, parseIntent, type ReadingForIntent } from '../../../../lib/parse-intent' @@ -20,6 +33,11 @@ import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { fetchOrderBookTop } from '../../../../lib/swap/limit-price' import type { OrderBookTop } from '../../../../lib/swap/limit-price' import type { SwapQuote } from '../../../../lib/swap/quote' +import { + applyChainMeasure, + isUnmeasuredSwap, + settleChainPlan, +} from '../../../../lib/agents/settle-chain-plan' import { resolveExecutionPlan } from '../../../../lib/agents/tool-schema' import type { ParsedIntent } from '../../../../lib/parse-intent' import { logRace, type RaceInput } from '../../../../lib/server/analytics' @@ -57,7 +75,7 @@ const AGENT_TIMEOUT_MS = 60_000 const WINDOW_SECONDS = 30 /** - * Chains a competition can execute on. + * Chains a competition can execute on: Stellar, and any chain with a module. * * Checked before anything runs, and by name. The chain used to default to * 'arc' when the body carried none, and an unrecognised slug fell through to @@ -65,8 +83,95 @@ const WINDOW_SECONDS = 30 * which the agents were offered Uniswap. Now a missing or unsupported chain is * an answer the user reads, not a race that quietly runs on the wrong facts. */ -const EXECUTING_CHAINS: Record = { - stellar: 'Stellar', +function canExecuteOn(chain: string): boolean { + return chain === 'stellar' || chainModule(chain) !== undefined +} + +const CHAIN_ACTION_KINDS: readonly ChainActionKind[] = ['swap', 'supply', 'stake'] + +/** The longest account a chain uses is a 64-character NEAR implicit account; Sui and EVM are shorter. */ +const MAX_ACCOUNT_CHARS = 128 + +/** + * Reads the structured action from the body, or nothing. Only the shape is + * checked here; whether the tokens are real is checked against the chain. + */ +function chainActionFromBody(raw: unknown): ChainActionRequest | undefined { + if (typeof raw !== 'object' || raw === null) return undefined + const { kind, amount, tokenIn, tokenOut, venue } = raw as Record + if (typeof kind !== 'string' || !CHAIN_ACTION_KINDS.includes(kind as ChainActionKind)) { + return undefined + } + if (typeof amount !== 'string' || typeof tokenIn !== 'string') return undefined + return { + kind: kind as ChainActionKind, + amount: amount.slice(0, 40), + tokenIn: tokenIn.slice(0, 40), + ...(typeof tokenOut === 'string' ? { tokenOut: tokenOut.slice(0, 40) } : {}), + ...(typeof venue === 'string' ? { venue: venue.slice(0, 40) } : {}), + } +} + +/** + * What the agents are given for an action on a chain that is not Stellar: the + * chain's own venues and real quotes, and none of Stellar's prices, assets or + * order book. Returns the sentence to show instead when there is nothing to race. + * A `refusal` is the action itself being refused, which the user must read as + * written; a plain string is a race that could not run, where the direct route + * is still worth trying. + */ +async function chainRaceFacts( + chain: string, + chainApi: ChainModule, + action: ChainActionRequest, + account: string | undefined, + text: string +): Promise<{ intent: ParsedIntent; market: MarketContext } | string | { refusal: string }> { + // No venue to quote, and none for the direct route to build on, so nothing is worth trying. + if (!chainApi.venues.some((v) => v.kinds.includes(action.kind))) { + return { + refusal: `Nothing here can ${action.kind} on this chain yet, so nothing was signed or sent.`, + } + } + try { + const known = assertKnownTokens(chainApi, action) + await assertAmountWithinCap(chainApi, action, known) + } catch (e) { + if (e instanceof ChainActionRefused) + return { refusal: `${e.message} Nothing was signed or sent.` } + throw e + } + if (account === undefined) { + return 'No wallet account came with this request, so nothing could be priced. Nothing was signed or sent.' + } + + const routes = await quoteChainRoutes(chainApi, action, account) + if (routes.length === 0) { + return 'No route on this chain could be priced right now, so the agents had nothing to judge. Nothing was signed or sent.' + } + + const tokenIn = chainApi.tokens.find( + (t) => t.symbol.toUpperCase() === action.tokenIn.toUpperCase() + ) + const price = + tokenIn === undefined ? undefined : await chainApi.priceUsd(tokenIn).catch(() => undefined) + + // The chain's market already leaves out Stellar's assets, prices, lending + // rates and anchors; see `buildMarketContext`. + const base = await buildMarketContextAsync(chain) + const market: MarketContext = { + ...base, + prices: price !== undefined && tokenIn !== undefined ? { [tokenIn.symbol]: price } : {}, + venues: chainApi.venues + .filter((v) => routes.some((r) => r.id === `${v.id}-1`)) + .map((v) => ({ + id: v.id, + name: v.name, + category: action.kind === 'swap' ? 'dex' : action.kind === 'supply' ? 'lending' : 'staking', + })), + routes, + } + return { intent: intentFromChainAction(action, text, price), market } } /** @@ -120,10 +225,11 @@ function settlePlan( */ function measured( proposal: AgentProposalResult, - quote: SwapQuote | undefined, + quote: SwapQuote | ChainRouteQuote | undefined, prices: Record ): AgentProposalResult { if (quote === undefined) return proposal + if ('kind' in quote && quote.kind === 'chain') return applyChainMeasure(proposal, quote) const m = measureRoute(quote, prices) if (m === undefined) return proposal return { @@ -134,7 +240,10 @@ function measured( } /** One SSE stream carrying a single error frame, then closing. */ -function errorStream(code: 'agents_offline' | 'chain_unsupported', message: string): Response { +function errorStream( + code: 'agents_offline' | 'chain_unsupported' | 'action_refused', + message: string +): Response { const encoder = new TextEncoder() const stream = new ReadableStream({ start(controller) { @@ -167,7 +276,7 @@ export async function POST(request: Request): Promise { b !== null && 'account' in b && typeof b.account === 'string' && - b.account.length <= 64 + b.account.length <= MAX_ACCOUNT_CHARS ? b.account : undefined ) @@ -180,6 +289,7 @@ export async function POST(request: Request): Promise { let chain: string let context: string[] let reading: ReadingForIntent | undefined + let chainAction: ChainActionRequest | undefined try { const body = (await request.json()) as { @@ -187,13 +297,17 @@ export async function POST(request: Request): Promise { chain?: unknown context?: unknown reading?: unknown + chainAction?: unknown } if (typeof body.text !== 'string' || body.text.trim() === '') { return NextResponse.json({ error: 'invalid_text' }, { status: 400 }) } text = body.text.trim().slice(0, MAX_INTENT_CHARS) - await ensureLiveAssets(tickersIn(text)) chain = typeof body.chain === 'string' ? body.chain.trim() : '' + chainAction = chainActionFromBody(body.chainAction) + // Stellar's live-asset lookup is for Stellar's tickers; a chain action + // names its tokens itself and they are checked against the chain's own list. + if (chain === 'stellar' || chainAction === undefined) await ensureLiveAssets(tickersIn(text)) reading = readingFromBody(body.reading, text) context = Array.isArray(body.context) ? body.context.filter((line): line is string => typeof line === 'string').slice(0, 8) @@ -211,11 +325,17 @@ export async function POST(request: Request): Promise { 'No chain was named for this intent. Reload the app and try again.' ) } - if (EXECUTING_CHAINS[chain] === undefined) { + if (!canExecuteOn(chain)) { + return errorStream( + 'chain_unsupported', + 'Agents can execute only on Stellar, Solana, Sui, NEAR, Avalanche and Monad right now. ' + + 'Switch the chain to run a competition.' + ) + } + if (chain !== 'stellar' && chainAction === undefined) { return errorStream( 'chain_unsupported', - `Agents can execute only on ${Object.values(EXECUTING_CHAINS).join(', ')} right now. ` + - `Switch the chain to run a competition.` + 'This request did not say what to do on this chain, so no race was run. Nothing was signed or sent.' ) } @@ -223,7 +343,10 @@ export async function POST(request: Request): Promise { if (full === undefined) { return errorStream( 'agents_offline', - 'The agents are not online right now, so nothing was proposed. Nothing can be executed until they are.' + // Off Stellar the chat builds the direct route next, so something can still be signed. + chain === 'stellar' + ? 'The agents are not online right now, so nothing was proposed. Nothing can be executed until they are.' + : 'The agents are not online right now, so nothing was proposed.' ) } const roster = lineUp(full) @@ -236,45 +359,60 @@ export async function POST(request: Request): Promise { ) } + // Off Stellar the facts come from the chain's own venues and quotes, and a + // refusal or an unpriceable action ends here, before any agent is called. + const chainApi = chain === 'stellar' ? undefined : chainModule(chain) + const chainFacts = + chainApi !== undefined && chainAction !== undefined + ? await chainRaceFacts(chain, chainApi, chainAction, account, text) + : undefined + if (typeof chainFacts === 'string') return errorStream('chain_unsupported', chainFacts) + if (chainFacts !== undefined && 'refusal' in chainFacts) { + return errorStream('action_refused', chainFacts.refusal) + } + const competitionId = randomUUID() // Taken here, in the request, so the race is recorded for the network it ran on. const network = activeNetwork() const startedAt = new Date() // Prices are fetched first so the parser can size "$30 of XLM" against the // real market rather than an indicative table. - const market = await buildMarketContextAsync(chain) + const market = chainFacts?.market ?? (await buildMarketContextAsync(chain)) // A structured reading, when the chat has one, is what the agents are given. // Re-reading the words would lose a paraphrase that no keyword recognises. const intent = - reading !== undefined + chainFacts?.intent ?? + (reading !== undefined ? buildParsedIntent(reading, market.prices) - : parseIntent(text, market.prices) + : parseIntent(text, market.prices)) // Priced before the agents run, so they choose between real routes rather // than describing hypothetical ones. Failing to quote is not fatal: the // competition proceeds without executable routes and says so. - try { - const routes = await quoteRoutes( - chain, - intent.input.tokenIn, - intent.input.tokenOut, - toBaseUnits(intent.input.amountIn), - undefined, - // A limit order states a price it will not trade through, expressed as - // a minimum output so the quoter can decline. Keyed on `limitPriceUsd` - // rather than `targetPriceUsd`: the latter falls back to spot when the - // user named no price, and a limit derived from the market is not one. - isLimitType(intent.input.type) && intent.limitPriceUsd !== undefined - ? { - minReceive: toBaseUnits( - (Number(intent.input.amountIn) * intent.limitPriceUsd).toFixed(7) - ), - } - : {} - ) - if (routes.length > 0) market.routes = routes - } catch { - // Leaving routes unset is the honest outcome; agents reason without them. + if (chainFacts === undefined) { + try { + const routes = await quoteRoutes( + chain, + intent.input.tokenIn, + intent.input.tokenOut, + toBaseUnits(intent.input.amountIn), + undefined, + // A limit order states a price it will not trade through, expressed as + // a minimum output so the quoter can decline. Keyed on `limitPriceUsd` + // rather than `targetPriceUsd`: the latter falls back to spot when the + // user named no price, and a limit derived from the market is not one. + isLimitType(intent.input.type) && intent.limitPriceUsd !== undefined + ? { + minReceive: toBaseUnits( + (Number(intent.input.amountIn) * intent.limitPriceUsd).toFixed(7) + ), + } + : {} + ) + if (routes.length > 0) market.routes = routes + } catch { + // Leaving routes unset is the honest outcome; agents reason without them. + } } // One read for the whole competition: every agent's resting price is checked @@ -282,7 +420,7 @@ export async function POST(request: Request): Promise { let book: OrderBookTop | undefined const from = resolveAsset(intent.input.tokenIn) const to = resolveAsset(intent.input.tokenOut) - if (from !== undefined && to !== undefined) { + if (chainFacts === undefined && from !== undefined && to !== undefined) { const base = from.issuer === undefined ? from : to const counter = from.issuer === undefined ? to : from try { @@ -312,7 +450,9 @@ export async function POST(request: Request): Promise { ): Promise => logRace({ id: competitionId, - network, + // Other chains only run on mainnet, and their submits are logged there too. + network: chain === 'stellar' ? network : 'mainnet', + chain, startedAt, durationMs: Date.now() - startedAt.getTime(), intent: { @@ -334,6 +474,7 @@ export async function POST(request: Request): Promise { // "thinking". Each agent is a model, and the user can see which. agents: roster.map(({ key, name, gradient, model }) => ({ key, name, gradient, model })), windowSeconds: WINDOW_SECONDS, + ...(chainFacts !== undefined ? { routeCount: market.routes?.length ?? 0 } : {}), }) // Agents run concurrently and are emitted as they finish, so one slow @@ -382,8 +523,10 @@ export async function POST(request: Request): Promise { const own = (market.routes ?? []).find((r) => r.id === outcome.proposal.routeId) const proposal = measured( - settlePlan(outcome.proposal, intent, book), - own?.quote as SwapQuote | undefined, + chainFacts === undefined + ? settlePlan(outcome.proposal, intent, book) + : settleChainPlan(outcome.proposal), + own?.quote as SwapQuote | ChainRouteQuote | undefined, market.prices ) @@ -430,10 +573,27 @@ export async function POST(request: Request): Promise { // What the user actually asked for, so a follow-on nobody requested // can be told apart from one they did. The same parser the sequence // flow uses, rather than a second opinion about the same sentence. - const compound = parseCompoundIntent(text, market.prices) + const compound = + chainFacts === undefined ? parseCompoundIntent(text, market.prices) : undefined const followOn = compound?.followOn.kind const anchorLimits = (market.offramps ?? []).find((o) => o.asset === 'USDC') + // A supply or a stake is ranked on the rate each route shows. + const apyEntries = (market.routes ?? []).flatMap((r) => { + const q = r.quote as Partial | undefined + return q?.kind === 'chain' && q.apyPercent !== undefined + ? [[r.id, q.apyPercent] as const] + : [] + }) + const apyByRoute = apyEntries.length > 0 ? Object.fromEntries(apyEntries) : undefined + + const unmeasuredRoutes = (market.routes ?? []) + .filter((r) => { + const q = r.quote as Partial | undefined + return q?.kind === 'chain' && isUnmeasuredSwap(q as ChainRouteQuote) + }) + .map((r) => r.id) + const plan: PlanContext = { escrowUsd: intent.escrowUsd, referencePriceUsd: intent.referencePriceUsd, @@ -449,6 +609,8 @@ export async function POST(request: Request): Promise { ...(book?.ask !== undefined ? { recentHighUsd: book.ask } : {}), ...(anchorLimits?.maxAmount !== undefined ? { offrampMaxUsd: anchorLimits.maxAmount } : {}), ...(anchorLimits?.minAmount !== undefined ? { offrampMinUsd: anchorLimits.minAmount } : {}), + ...(apyByRoute !== undefined ? { apyByRoute } : {}), + ...(unmeasuredRoutes.length > 0 ? { unmeasuredRoutes } : {}), } const scored = scoreProposals(proposals, { competitionId, plan }) diff --git a/apps/dapp/app/api/agents/history/route.ts b/apps/dapp/app/api/agents/history/route.ts index e4aaf623..532d825a 100644 --- a/apps/dapp/app/api/agents/history/route.ts +++ b/apps/dapp/app/api/agents/history/route.ts @@ -2,6 +2,7 @@ import { activeNetwork } from '@intent/config' import { StrKey } from '@stellar/stellar-sdk' import { NextResponse } from 'next/server' +import { isWalletAddress } from '../../../../lib/wallet/address' import { databaseConfigured } from '../../../../lib/server/db' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { getAnalyticsRepo } from '../../../../lib/server/analytics' @@ -13,6 +14,15 @@ export const dynamic = 'force-dynamic' const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 +const STELLAR_SHAPE = /^G[A-Z2-7]{55}$/ + +/** A Stellar address is checked by its checksum; the other chains' by the shape the app accepts. */ +function validAccount(account: string): boolean { + return STELLAR_SHAPE.test(account) + ? StrKey.isValidEd25519PublicKey(account) + : isWalletAddress(account) +} + /** * A wallet's own agent races, each with the winning agent's reasoning and * the transaction this wallet actually submitted for it, when there was one. @@ -34,7 +44,7 @@ export async function GET(request: Request): Promise { const url = new URL(request.url) const account = url.searchParams.get('account') - if (account === null || !StrKey.isValidEd25519PublicKey(account)) { + if (account === null || !validAccount(account)) { return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) } @@ -49,7 +59,8 @@ export async function GET(request: Request): Promise { try { const repo = await getAnalyticsRepo() const result = await repo.historyForAccount({ - network: activeNetwork(), + // Other chains' actions are logged as mainnet, whatever Stellar network the page is on. + network: STELLAR_SHAPE.test(account) ? activeNetwork() : 'mainnet', account, page, pageSize, diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index 0d58363b..5d5e868d 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -1,3 +1,4 @@ +import { CHAIN_DESCRIPTORS } from '@intent/config' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -10,6 +11,8 @@ import { isWalletAddress } from '../../../../../lib/wallet/address' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +// A send waits for the network to confirm, and a 1Click swap then waits on 1Click too. +export const maxDuration = 60 const Signed = z.discriminatedUnion('family', [ z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }), @@ -22,6 +25,8 @@ const Signed = z.discriminatedUnion('family', [ family: z.literal('near'), hashes: z.array(z.string().min(1).max(128)).min(1).max(8), }), + // The venue checks its shape, so an odd answer from a wallet that may have sent is read as pending. + z.object({ family: z.literal('evm'), hash: z.string().max(128) }), ]) const Body = z.object({ @@ -54,20 +59,37 @@ export async function POST( const venue = chainApi.venues.find((v) => v.id === venueId) if (venue === undefined) return NextResponse.json({ error: 'unknown_venue' }, { status: 404 }) - if (signed.family !== chainApi.chain) { + // By family, since Avalanche and Monad are both EVM. + if (signed.family !== CHAIN_DESCRIPTORS[chainApi.chain].family) { return NextResponse.json({ error: 'wrong_chain' }, { status: 400 }) } - try { - // A NEAR wallet has already sent its transactions, so there are no bytes left to check. - if (signed.family !== 'near') { - const payload: SigningPayload = - signed.family === 'solana' - ? { family: 'solana', transaction: signed.signedTransaction } - : { family: 'sui', transaction: signed.transaction } - venue.assertSafe(payload, account) + // NEAR and EVM wallets have already sent, so there are no bytes left to check. Their venues + // ask the chain what was sent instead. + if (signed.family !== 'near' && signed.family !== 'evm') { + const payload: SigningPayload = + signed.family === 'solana' + ? { family: 'solana', transaction: signed.signedTransaction } + : { family: 'sui', transaction: signed.transaction } + try { + venue.assertSafe(payload, account, context) + } catch (e) { + // This route is what sends the bytes, so any failure here means they never left. + if (e instanceof ChainActionRefused) { + return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) + } + reportError('chain/submit', e, { chain: chainApi.chain }) + return NextResponse.json( + { + ok: false, + error: 'This transaction could not be checked, so nothing was sent. Build it again.', + }, + { status: 422 } + ) } + } + try { const input = { account, venueId, signed, ...(context !== undefined ? { context } : {}) } const result = await (venue.submit ?? chainApi.submit)(input) await logExecution({ @@ -82,17 +104,17 @@ export async function POST( status: result.ok ? 200 : result.pending === true ? 202 : 422, }) } catch (e) { - if (e instanceof ChainActionRefused) { - return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) - } reportError('chain/submit', e, { chain: chainApi.chain }) return NextResponse.json( { ok: false, + // Sending may have started, so no throw from here on can prove nothing moved. + // Sent with no outcome known: the same contract as the cash-out route. + pending: true, error: "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", }, - { status: 502 } + { status: 202 } ) } } diff --git a/apps/dapp/components/apps/venue-grid.tsx b/apps/dapp/components/apps/venue-grid.tsx index 85f0b126..2b5a9a1c 100644 --- a/apps/dapp/components/apps/venue-grid.tsx +++ b/apps/dapp/components/apps/venue-grid.tsx @@ -6,7 +6,7 @@ import { Search, X } from 'lucide-react' import { useMemo, useState } from 'react' import { useChain } from '../../providers/chain-provider' -import { isListedOn, notOnNetworkLabel, venueDetail, venues } from '../../lib/venues' +import { isListedOn, notOnNetworkLabel, venueDetail, venueOnChain, venues } from '../../lib/venues' import { categoryLabel, VenueCard, VenueLogo, VerifiedTick } from './venue-card' /** Integrated first, then quoted, then merely listed. */ @@ -42,7 +42,7 @@ export function VenueGrid({ const rows = useMemo( () => venues - .filter((v) => v.family === descriptor.family && isListedOn(v)) + .filter((v) => venueOnChain(v, descriptor) && isListedOn(v)) // Venues the app can actually use come first. Alphabetical order put // Aquarius — then only priced, never signed — above Soroswap, which // intents routed through, so the most useful thing was buried. @@ -55,7 +55,7 @@ export function VenueGrid({ live: notHere === undefined && (venue.integration ?? 'listed') !== 'listed', } }), - [descriptor.family, availableIds] + [descriptor, availableIds] ) const categories = useMemo(() => { diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 9011730c..7905ea52 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -5,9 +5,9 @@ import { ExternalLink, Loader2 } from 'lucide-react' import type { ChainAction } from '../../hooks/use-chain-action' -/** Review, sign and result for an action on Solana, Sui or NEAR. */ +/** Review, sign and result for an action on a chain other than Stellar. */ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element { - const { phase, prepared, error, hash, explorerUrl } = action + const { phase, prepared, error, hash, explorerUrl, earlier } = action const busy = phase === 'signing' || phase === 'submitting' return ( @@ -74,6 +74,29 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele ) : null} + {phase === 'review' && earlier !== undefined ? ( +

+ {earlier.text} + {earlier.explorerUrl !== undefined && earlier.hash !== undefined ? ( + + {`${earlier.hash.slice(0, 6)}…${earlier.hash.slice(-4)}`} + + ) : null} +

+ ) : null} + + {phase === 'review' && error !== undefined ? ( +

+ {error} +

+ ) : null} + {phase === 'settled' ? (

Sent diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index fb92b356..699b2dd3 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react' import type { CompetitionState } from '../../lib/agents/competition' +import { panelErrorMessage, recommendationLine, singleRouteNote } from '../../lib/agents/chain-race' import { describePlan } from '../../lib/agents/competition' import { AgentAvatar } from '../agents/agent-avatar' import { agentBubble, systemPill } from './bubble' @@ -84,6 +85,9 @@ export function CompetitionPanel({ onExecute, executingKey, locked = false, + chainName, + directRoute = false, + signed = false, }: { state: CompetitionState onExecute: (key: string) => void @@ -95,6 +99,12 @@ export function CompetitionPanel({ * disabling the buttons on the first pick made it one. */ locked?: boolean + /** The chain's name, set off Stellar so a single route can be explained. */ + chainName?: string + /** The direct route is being offered instead, so the error must not say agents can't act here. */ + directRoute?: boolean + /** Something may have been signed, so "nothing was signed" must not be said. */ + signed?: boolean }): JSX.Element | null { const { proposals, revealed, agents, phase, winner, error } = state const unanimous = state.unanimous === true @@ -106,6 +116,10 @@ export function CompetitionPanel({ // race that was not happening and never ended. if (phase === 'idle') return null + // A refusal of the action itself is shown by the chat, as written, in the + // action card; saying "agents are not online" over it would be wrong. + if (error?.code === 'action_refused') return null + if (error !== undefined) { return (

@@ -113,12 +127,14 @@ export function CompetitionPanel({

{error.code === 'chain_unsupported' - ? 'Agents cannot execute here' + ? directRoute + ? 'The agents did not race' + : 'Agents cannot execute here' : error.code === 'no_agent_answered' ? 'No agent answered' : 'Agents are not online'}

-

{error.message}

+

{panelErrorMessage(error.message, signed)}

) @@ -133,8 +149,12 @@ export function CompetitionPanel({ const pendingAgents = decided ? [] : agents.filter((a) => !revealed[a.key] && answered(a.key)) const anyAnswered = Object.values(proposals).some((p) => p.failed === undefined) + const routeNote = + chainName !== undefined ? singleRouteNote(state.routeCount, chainName) : undefined + return (
+ {routeNote !== undefined ?
{routeNote}
: null} {revealedAgents.length === 0 && !decided ? (
@@ -241,11 +261,12 @@ export function CompetitionPanel({ oracle's fair value. */}
- {money(proposal?.avgPriceUsd ?? 0)} avg + {proposal?.figures?.headline ?? + `${money(proposal?.avgPriceUsd ?? 0)} avg`} {[ - vsOracle(proposal?.vsOraclePct), + proposal?.figures !== undefined ? '' : vsOracle(proposal?.vsOraclePct), proposal?.executionMode === 'rest' && proposal.horizonMinutes !== undefined && proposal.horizonMinutes > 0 @@ -311,10 +332,11 @@ export function CompetitionPanel({ {/* Recommended on the measured fill, which is the only thing scored. "Strongest strategy" implied a judgement of the reasoning; there is none — the route's real output decides, and a tie is broken - by a draw the agents cannot influence. */} + by a draw the agents cannot influence. A supply or a stake is + ranked on its rate instead, and the line says that rate. */} {unanimous ? `All ${Object.values(proposals).filter((p) => p.failed === undefined).length} agents agree: ${describePlan(proposals[winner] ?? {})}. Pick any to execute.` - : `Recommended: ${proposals[winner]?.name}, best measured fill. You pick who executes.`} + : recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)} ) : null}
diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 629b1cd4..c6c2b4af 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -24,6 +24,16 @@ import { StrategyEditor, type StrategyEdits } from './strategy-editor' import { DEFAULT_SLIPPAGE_BPS } from '../../lib/swap/build-tx' import { ACKNOWLEDGED } from '../../lib/chat/clarify-flow' import type { ClarifyTurn } from '../../lib/clarify/clarify' +import { + chainReviewClosed, + chainSigning, + isChainRoute, + needsDirectRoute, + reviewChainRoute, + routeForSwap, + selectionHolds, + showDirectNote, +} from '../../lib/agents/chain-race' import { agentsFromProposals } from '../../lib/agents/competition' import type { CreateIntentInput } from '@intent/types' @@ -55,6 +65,7 @@ import { ensureSession } from '../../lib/api/auth' import { chainCapabilities } from '../../lib/chains/capabilities' import { chainHelp } from '../../lib/chains/explain' import { looksLikeChainAction, parseChainAction } from '../../lib/chains/parse-action' +import type { ChainActionRequest } from '../../lib/chains/types' import { useChainAction } from '../../hooks/use-chain-action' import { ChainActionConfirm } from './chain-action-confirm' import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout' @@ -188,6 +199,14 @@ export function IntentChat(): JSX.Element { const [message, setMessage] = useState(null) const [parsed, setParsed] = useState(null) const [executingKey, setExecutingKey] = useState(null) + // A swap, supply or stake typed on a chain other than Stellar, while its race + // runs. `parsed` stays null for it so Stellar's cards do not appear. + const [chainRace, setChainRace] = useState<{ request: ChainActionRequest; text: string } | null>( + null + ) + // Set when the race left nothing to review and the direct route was built, so + // the card can say why. + const [directFallback, setDirectFallback] = useState(false) // The intent placed in this conversation, so an order that is waiting for a // price can be watched — and withdrawn — without leaving the chat. const [placedId, setPlacedId] = useState(null) @@ -405,8 +424,9 @@ export function IntentChat(): JSX.Element { const live = useCompetition( restored === null ? parsed : null, slug, - [...raceContext(thread, notes), ...brief], - address + chainRace !== null ? [] : [...raceContext(thread, notes), ...brief], + address, + restored === null && chainRace !== null ? chainRace : undefined ) const liveCompetition = live @@ -440,7 +460,9 @@ export function IntentChat(): JSX.Element { const winnerRoute = restored !== null ? undefined : live.route const chosenRoute = useMemo(() => { if (executingKey === null) return undefined - return routesByAgent[executingKey] ?? winnerRoute + // A chain route never reaches the Stellar swap hook: it has no sendAmount, + // and would be read as a Stellar quote. + return routeForSwap(routesByAgent[executingKey] ?? winnerRoute) }, [executingKey, routesByAgent, winnerRoute]) const swap = useSwapExecution(chosenRoute, strategy.slippageBps) @@ -456,6 +478,54 @@ export function IntentChat(): JSX.Element { // no earlier step whose output has to be carried. const supply = useSupply() const chainAction = useChainAction() + // A new race gets a clean slate when the chain changes. + useEffect(() => { + clearChainRace() + chainAction.release() + chainAction.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) + // Selected is shown only while a build or its aftermath is live; a failed + // build must not leave an agent looking chosen. + useEffect(() => { + if (chainRace !== null && !selectionHolds(chainAction.phase)) setExecutingKey(null) + }, [chainRace, chainAction.phase]) + const chainConfirmRef = useRef(null) + const chainAwaiting = chainAction.phase === 'review' + useEffect(() => { + if (!chainAwaiting) return + chainConfirmRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [chainAwaiting]) + // The race ended with nothing to review: build the direct route once, so the + // chain keeps working when the agents are offline. A refusal of the action + // is shown as written by the card and is not retried. + const fellBackRef = useRef(null) + // The race the hook has actually begun. On the render where a new race is + // asked for, the hook still holds the last one's ending, which must not be + // mistaken for this race's. + const startedRef = useRef(null) + const raceEnded = needsDirectRoute({ + phase: live.phase, + winner: live.winner, + anyAnswered: Object.values(live.proposals).some((p) => p.failed === undefined), + ...(live.error !== undefined ? { error: live.error } : {}), + }) + useEffect(() => { + if (chainRace === null || restored !== null || fellBackRef.current === chainRace) return + if (live.phase === 'competing') startedRef.current = chainRace + if (startedRef.current !== chainRace) return + if (live.error?.code === 'action_refused') { + fellBackRef.current = chainRace + chainAction.refuse(live.error.message, chainRace.text) + return + } + if (!raceEnded) return + fellBackRef.current = chainRace + setDirectFallback(true) + chainAction.prepare(chainRace.request, chainRace.text) + // chainAction changes identity with its state; the race ending is the trigger. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainRace, raceEnded, live.error, live.phase, restored]) const cashout = useCashout() const [cashoutQuestion, setCashoutQuestion] = useState(null) // A leveraged position on Noether. Its own shape again: no route, no @@ -725,7 +795,8 @@ export function IntentChat(): JSX.Element { swap.phase === 'signing' || swap.phase === 'submitting' || sequence.phase === 'signing' || - sequence.phase === 'submitting' + sequence.phase === 'submitting' || + chainSigning(chainAction.phase) /** Something came of the open intent: a trade settled, or an order was placed. */ const turnExecuted = swap.phase === 'settled' || sequence.phase === 'settled' || placedId !== null @@ -762,8 +833,15 @@ export function IntentChat(): JSX.Element { setNotes([]) } + /** Ends a chain race and what hangs off it; the direct-route note belongs to the race. */ + function clearChainRace(): void { + setChainRace(null) + setDirectFallback(false) + } + /** What a previous attempt left on screen, which a new turn must not inherit. */ function resetTurnState(): void { + clearChainRace() sequence.reset() swap.reset() supply.reset() @@ -1326,6 +1404,13 @@ export function IntentChat(): JSX.Element { return } setMessage(text) + // A chain race in progress ends here; resetting the card also drops any + // build still in flight, so it cannot land over the cash-out. + if (chainRace !== null) { + clearChainRace() + chainAction.reset() + setExecutingKey(null) + } setRestored(null) setHistoryOpen(false) setTurnId(null) @@ -1450,6 +1535,12 @@ export function IntentChat(): JSX.Element { // A misspelled verb is corrected before anything reads the sentence. const text = fixVerbTypo(typed) + // A chain signature is with the wallet: a new message must not close its card. + if (chainSigning(chainAction.phase)) { + setAffordError('Finish or cancel the signature first, then continue the chat.') + return + } + const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet()) if (cashoutRoute.kind !== 'pass') { startCashout(cashoutRoute, text) @@ -1479,7 +1570,7 @@ export function IntentChat(): JSX.Element { cashout.dismiss() if (chainCapabilities(slug) === undefined) chainAction.reset() - // Solana, Sui and NEAR have their own path: the race and the swap routes + // Chains with their own module have their own path: the race and the swap routes // below are Stellar's. Anything that isn't an instruction, such as a // question, still goes to the orchestrator. const capabilities = chainCapabilities(slug) @@ -1489,15 +1580,27 @@ export function IntentChat(): JSX.Element { // The thread only renders once there is a message, so without this the // card below would be built and never seen. setMessage(text) - chainAction.prepare(action, text) + // A reopened turn, an open Stellar intent and its saved id all end here, + // so the race is the only thing on screen and is not saved over them. + resetTurnState() + setParsed(null) + setTurnId(null) + setAffordError(null) + // The agents race first; Review on a route is what builds it. A new + // race is the one thing that lifts the "already signed" latch. + chainAction.release() + chainAction.reset() + setChainRace({ request: action, text }) return } if (looksLikeChainAction(text)) { setMessage(text) + clearChainRace() chainAction.refuse(chainHelp(descriptor.name, capabilities), text) return } chainAction.reset() + clearChainRace() } // An answer to a question already asked. @@ -1699,11 +1802,15 @@ export function IntentChat(): JSX.Element { } function handleReset(): void { + // The card holds a signature in flight; closing it would orphan the transaction. + if (chainSigning(chainAction.phase)) return + if (slug !== 'stellar') chainAction.reset() // The thread stays while a cash-out is under way: it carries the warning not to resend. setMessage(cashout.locked ? message : null) cashout.dismiss() setCashoutQuestion(null) setParsed(null) + clearChainRace() setExecutingKey(null) setPlacedId(null) setAffordError(null) @@ -1751,6 +1858,31 @@ export function IntentChat(): JSX.Element { // A signature in flight is the real reason to refuse: past that point a // transaction is with the wallet or the network, and switching routes // would leave the wrong one submitted. + // A race on another chain signs through that chain's own card, never the + // Stellar swap hook. + if (slug !== 'stellar' && chainRace !== null) { + const outcome = reviewChainRoute({ + // Only this agent's own route: falling back to the winner's would sign + // a trade the user did not pick. + route: routesByAgent[key], + asked: chainRace.request, + text: chainRace.text, + phase: chainAction.phase, + signed: chainAction.signed === true, + preparedVenueId: chainAction.prepared?.venueId, + ...(live.competitionId !== undefined ? { raceId: live.competitionId } : {}), + prepare: chainAction.prepare, + refuse: chainAction.refuse, + }) + if (outcome === 'prepared') { + setDirectFallback(false) + setExecutingKey(key) + } + return + } + // A chain route with no race open (a reopened turn) is not for the Stellar path. + if (isChainRoute(routesByAgent[key] ?? winnerRoute)) return + const inFlight = swap.phase === 'signing' || swap.phase === 'submitting' || swap.phase === 'settled' if (!parsed || inFlight) return @@ -2145,6 +2277,12 @@ export function IntentChat(): JSX.Element { { + if (chainSigning(chainAction.phase)) return + // A race on screen ends when another turn is opened. + if (chainRace !== null) { + clearChainRace() + chainAction.reset() + } // Reopen exactly what was recorded. This used to resubmit the // text, which started a fresh competition and produced different // answers — the conversation was not being restored at all. @@ -2384,8 +2522,12 @@ export function IntentChat(): JSX.Element { locked={ swap.phase === 'signing' || swap.phase === 'submitting' || - swap.phase === 'settled' + swap.phase === 'settled' || + chainReviewClosed(chainAction.phase, chainAction.signed === true) } + directRoute={directFallback} + signed={chainAction.requested} + {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once the agents have answered and before anything is signed. */} @@ -2560,7 +2702,16 @@ export function IntentChat(): JSX.Element { one signature — no sequence to explain. */} {supply.phase !== 'idle' ? : null} - {chainAction.phase !== 'idle' ? : null} + {showDirectNote(directFallback, chainAction.phase) ? ( +

+ The agents were unavailable, so this is the direct route. Nothing has been signed. +

+ ) : null} + {chainAction.phase !== 'idle' ? ( +
+ +
+ ) : null} {cashout.session !== null ? ( diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index 4d623aa2..6d9a3e6f 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -1,8 +1,25 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' -import type { ChainActionRequest, PreparedAction, SubmitResult } from '../lib/chains/types' +import type { ChainActionRequest, PreparedAction } from '../lib/chains/types' +import { + accountMismatch, + afterSigning, + afterSubmitting, + attemptNotice, + attemptStorage, + earlierAttempt, + latchBeforeSign, + noteSubmitAnswer, + noteWalletAnswer, + QUOTE_EXPIRED, + quoteExpiredBeforeSigning, + recordAttempt, + submitSigned, + type AttemptNotice, + type SignAttempt, +} from '../lib/chains/signing-flow' import { useChain } from '../providers/chain-provider' import { useWallet } from './use-wallet' @@ -24,10 +41,29 @@ export interface ChainActionState { hash?: string explorerUrl?: string error?: string + /** The account the payload was built and checked for. */ + account?: string + /** The race this action was chosen from; absent for the direct route built when no race gave one. */ + raceId?: string + /** This same action was already started recently, perhaps before a reload. */ + earlier?: AttemptNotice } export interface ChainAction extends ChainActionState { - prepare: (request: ChainActionRequest, asked: string) => void + /** + * A signature may have happened in this race, so nothing may be signed again. + * It belongs to the race, not the card: Dismiss, Done and reset leave it set, + * and only `release`, called when a new race starts, clears it. + */ + signed: boolean + /** + * A signature was asked of the wallet in this race. Unlike `signed`, a clean + * failure does not clear it: something was signed, so "nothing was signed" + * must never be said again. Only a new race clears it. + */ + requested: boolean + release: () => void + prepare: (request: ChainActionRequest, asked: string, raceId?: string) => void /** Shows a message in the card without building anything, for what can't be read. */ refuse: (message: string, asked: string) => void confirm: () => void @@ -40,21 +76,40 @@ const EMPTY: ChainActionState = { phase: 'idle' } export function useChainAction(): ChainAction { const { slug, adapter } = useChain() const { address } = useWallet() + // Read at the moment of signing, not the render the click was made in. + const addressRef = useRef(address) + addressRef.current = address + const [signed, setSigned] = useState(false) + const [requested, setRequested] = useState(false) + const release = useCallback(() => { + setSigned(false) + setRequested(false) + }, []) const [state, setState] = useState(EMPTY) + // Each prepare, refuse and reset starts a new generation. A response from an + // older one is dropped, so a build that was superseded cannot become a + // signable card under a newer request. + const generation = useRef(0) - const reset = useCallback(() => setState(EMPTY), []) + const reset = useCallback(() => { + generation.current += 1 + setState(EMPTY) + }, []) const refuse = useCallback((message: string, asked: string) => { + generation.current += 1 setState({ phase: 'failed', asked, error: message }) }, []) const prepare = useCallback( - (request: ChainActionRequest, asked: string) => { + (request: ChainActionRequest, asked: string, raceId?: string) => { if (address === undefined) { setState({ phase: 'failed', asked, error: 'Connect a wallet first.' }) return } const account = address + generation.current += 1 + const mine = generation.current async function run(): Promise { setState({ phase: 'building', asked }) @@ -64,6 +119,7 @@ export function useChainAction(): ChainAction { body: JSON.stringify({ account, request }), }) const body = (await res.json()) as { prepared?: PreparedAction; error?: string } + if (generation.current !== mine) return if (body.prepared === undefined) { setState({ phase: 'failed', @@ -72,10 +128,24 @@ export function useChainAction(): ChainAction { }) return } - setState({ phase: 'review', asked, prepared: body.prepared }) + const earlier = earlierAttempt(attemptStorage(), { + chain: slug, + account, + tokenIn: body.prepared.pay.symbol, + amount: body.prepared.pay.amount, + }) + setState({ + phase: 'review', + asked, + prepared: body.prepared, + account, + ...(raceId !== undefined ? { raceId } : {}), + ...(earlier !== undefined ? { earlier: attemptNotice(earlier) } : {}), + }) } void run().catch(() => { + if (generation.current !== mine) return setState({ phase: 'failed', asked, error: 'Could not reach the network.' }) }) }, @@ -84,60 +154,101 @@ export function useChainAction(): ChainAction { const confirm = useCallback(() => { const prepared = state.prepared - if (prepared === undefined || address === undefined) return - const account = address + const builtFor = state.account + if (prepared === undefined || builtFor === undefined) return + const account = builtFor + const raceId = state.raceId + const mine = generation.current + const current = (): boolean => generation.current === mine + + // The payload was checked against this account only. A different wallet + // connected since the card was built stays on review with the reason. + const wrongWallet = (): boolean => { + const message = accountMismatch(addressRef.current, account) + if (message === undefined) return false + setState((s) => ({ ...s, phase: 'review', error: message })) + return true + } + if (wrongWallet()) return async function run(prepared: PreparedAction): Promise { - setState((s) => ({ ...s, phase: 'signing' })) + // Checked again immediately before the wallet opens. + if (wrongWallet()) return + if (quoteExpiredBeforeSigning(prepared)) { + setState((s) => ({ ...s, phase: 'failed', error: QUOTE_EXPIRED })) + return + } + const family = prepared.signing.family + const storage = attemptStorage() + const attempt: SignAttempt = { + chain: slug, + account, + tokenIn: prepared.pay.symbol, + amount: prepared.pay.amount, + at: Date.now(), + } + recordAttempt(storage, attempt) + // A wallet that sends for itself may send and then report an error, so + // the latch is set before it is called, not after it answers. + if (latchBeforeSign(family)) setSigned(true) + setRequested(true) + setState((s) => { + const { error: _cleared, ...rest } = s + return { ...rest, phase: 'signing' } + }) const outcome = await adapter.signPrepared?.(prepared.signing) - if (outcome === undefined) { + const step = outcome === undefined ? undefined : afterSigning(outcome, family) + // Noted even for a card replaced since: the answer is as true either way. + noteWalletAnswer(storage, attempt, step) + if (!current()) return + if (step === undefined) { setState((s) => ({ ...s, phase: 'failed', error: 'This wallet cannot sign here yet.' })) return } - if (!outcome.ok) { - // Declining returns to review: the action is still on offer. - if (outcome.reason === 'rejected') { - setState((s) => ({ ...s, phase: 'review' })) - return - } - setState((s) => ({ - ...s, - phase: 'failed', - error: outcome.detail ?? 'The wallet could not sign this.', - })) + if (step.next === 'review') { + setState((s) => ({ ...s, phase: 'review' })) + return + } + if (step.next === 'unknown') { + setState((s) => ({ ...s, phase: 'pending', error: step.error })) + return + } + if (step.next === 'failed') { + setState((s) => ({ ...s, phase: 'failed', error: step.error })) return } + setSigned(true) setState((s) => ({ ...s, phase: 'submitting' })) - const res = await fetch(`/api/chain/${slug}/submit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const result = await submitSigned( + `/api/chain/${slug}/submit`, + { account, venueId: prepared.venueId, - signed: outcome.signed, + signed: step.signed, ...(prepared.context !== undefined ? { context: prepared.context } : {}), - }), - }) - const result = (await res.json()) as SubmitResult - if (!result.ok) { + }, + raceId + ) + noteSubmitAnswer(storage, attempt, result) + if (!current()) return + const done = afterSubmitting(result, family) + if (!done.latched) setSigned(false) + if (done.phase === 'settled') { setState((s) => ({ ...s, - phase: result.pending === true ? 'pending' : 'failed', - error: result.error, + phase: 'settled', + hash: done.hash, + ...(done.explorerUrl !== undefined ? { explorerUrl: done.explorerUrl } : {}), })) return } - setState((s) => ({ - ...s, - phase: 'settled', - hash: result.hash, - explorerUrl: result.explorerUrl, - })) + setState((s) => ({ ...s, phase: done.phase, error: done.error })) } void run(prepared).catch(() => { + if (!current()) return setState((s) => ({ ...s, phase: 'failed', @@ -145,7 +256,7 @@ export function useChainAction(): ChainAction { "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", })) }) - }, [state.prepared, address, adapter, slug]) + }, [state.prepared, state.account, state.raceId, adapter, slug]) - return { ...state, prepare, refuse, confirm, reset } + return { ...state, signed, requested, release, prepare, refuse, confirm, reset } } diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts index d1192678..f38298ab 100644 --- a/apps/dapp/hooks/use-competition.ts +++ b/apps/dapp/hooks/use-competition.ts @@ -11,6 +11,8 @@ import type { } from '../lib/agents/competition' import { DECIDE_AT, RACE_DURATION, WINDOW_SECONDS, revealFloor } from '../lib/agents/competition' import { decodeFrame } from '../lib/agents/events' +import type { ChainActionRequest } from '../lib/chains/types' +import { routeFigures, routeSource } from '../lib/agents/chain-race' import { setAttributedRace } from '../lib/api/network-fetch' import { planDecision, planReveal } from '../lib/agents/pacing' import type { ParsedIntent } from '../lib/parse-intent' @@ -44,6 +46,8 @@ export interface CompetitionWithRoute extends CompetitionState { * these, choosing any other agent silently signed the winner's trade. */ routesByAgent: Record + /** The id the server gave this race once it began. */ + competitionId?: string } export function useCompetition( @@ -63,7 +67,14 @@ export function useCompetition( * sent as `undefined`, so a disconnected caller is counted by IP only, the * same as before this existed. */ - account?: string + account?: string, + /** + * An action on a chain other than Stellar, as the user typed it. Runs the + * race in place of `parsed`, which stays null there so Stellar's cards do not + * appear. The request is sent as structured data: agents pick a route and + * never supply the amount or tokens. + */ + chainRace?: { request: ChainActionRequest; text: 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. @@ -84,13 +95,17 @@ export function useCompetition( const [route, setRoute] = useState(undefined) const [routesByAgent, setRoutesByAgent] = useState>({}) const [agents, setAgents] = useState([]) + const [routeCount, setRouteCount] = useState(undefined) + const [competitionId, setCompetitionId] = useState(undefined) // The same list, readable inside the stream loop's closures without a // stale render's copy. const agentsRef = useRef([]) const lastRevealRef = useRef(0) useEffect(() => { - if (parsed === null) { + setCompetitionId(undefined) + if (parsed === null && chainRace === undefined) { + setRouteCount(undefined) setProposals({}) setRevealed({}) setPhase('idle') @@ -174,6 +189,7 @@ export function useCompetition( setRoute(undefined) setRoutesByAgent({}) setAgents([]) + setRouteCount(undefined) agentsRef.current = [] // A stream that stalls without erroring would leave the panel waiting on @@ -206,7 +222,8 @@ export function useCompetition( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - text: parsed?.outcome ?? '', + text: chainRace?.text ?? parsed?.outcome ?? '', + ...(chainRace !== undefined ? { chainAction: chainRace.request } : {}), ...(parsed?.reading !== undefined ? { reading: parsed.reading } : {}), chain, ...(contextRef.current.length > 0 ? { context: contextRef.current } : {}), @@ -252,7 +269,9 @@ export function useCompetition( // visible before any of it has answered. agentsRef.current = frame.agents setAttributedRace(frame.competitionId) + setCompetitionId(frame.competitionId) setAgents(frame.agents) + setRouteCount(frame.routeCount) continue } @@ -272,7 +291,8 @@ export function useCompetition( if (frame.route !== undefined) { setRoutesByAgent((prev) => ({ ...prev, [key]: frame.route })) } - const source = (frame.route as { source?: unknown } | undefined)?.source + const figures = routeFigures(frame.route, chainRace?.request) + const source = routeSource(frame.route) setProposals((prev) => ({ ...prev, @@ -286,7 +306,8 @@ export function useCompetition( vsOraclePct: frame.proposal.projectedSlippagePct, score: 0, reasoning: frame.proposal.reasoning, - ...(typeof source === 'string' ? { source } : {}), + ...(source !== undefined ? { source } : {}), + ...(figures !== undefined ? { figures } : {}), sliceCount: frame.proposal.sliceCount, horizonMinutes: frame.proposal.horizonMinutes, executionMode: frame.proposal.executionMode, @@ -362,7 +383,7 @@ export function useCompetition( for (const t of timeouts) clearTimeout(t) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [parsed, chain, contextKey]) + }, [parsed, chain, contextKey, chainRace]) return { proposals, @@ -373,6 +394,8 @@ export function useCompetition( winner, unanimous, routesByAgent, + ...(competitionId !== undefined ? { competitionId } : {}), + ...(routeCount !== undefined ? { routeCount } : {}), ...(error !== undefined ? { error } : {}), ...(route !== undefined ? { route } : {}), } diff --git a/apps/dapp/lib/__tests__/agent-history-client.test.ts b/apps/dapp/lib/__tests__/agent-history-client.test.ts index 13351441..47d5108a 100644 --- a/apps/dapp/lib/__tests__/agent-history-client.test.ts +++ b/apps/dapp/lib/__tests__/agent-history-client.test.ts @@ -5,6 +5,7 @@ import { agentHistoryByHash, fetchAgentHistory, type AgentHistoryEntry } from '. function entry(overrides: Partial = {}): AgentHistoryEntry { return { raceId: 'r1', + chain: 'stellar', startedAt: '2026-10-07T10:00:00.000Z', tokenIn: 'XLM', tokenOut: 'USDC', diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts new file mode 100644 index 00000000..f4992c0d --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -0,0 +1,558 @@ +import { StrKey } from '@stellar/stellar-sdk' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { networkFetch, setAttributedRace } from '../api/network-fetch' +import { needsDirectRoute, reviewChainRoute } from '../agents/chain-race' +import { submitSigned } from '../chains/signing-flow' +import { fakeAnalyticsDb, type FakeAnalyticsDb } from './fakes/analytics-db' + +/** + * The race on a chain other than Stellar. The chain module, the roster and the + * market are faked; under test is that a chain action starts a race, that a + * refusal or an unpriceable action never reaches an agent, and that Stellar + * keeps its old path. + */ + +const state = vi.hoisted(() => ({ + roster: undefined as unknown, + module: undefined as unknown, + propose: undefined as unknown as ReturnType, + db: undefined as unknown as FakeAnalyticsDb, + headers: {} as Record, +})) + +vi.mock('../server/db', () => ({ + databaseConfigured: () => true, + getPool: () => ({ + query: async (sql: string, params?: unknown[]) => state.db.query(sql, params), + }), + withTimeout: (work: Promise): Promise => work, +})) +vi.mock('next/headers', () => ({ headers: async () => new Headers(state.headers) })) +const enforceRateLimit = vi.fn(async () => undefined) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit })) +vi.mock('../agents/registry', () => ({ getRoster: () => state.roster })) +vi.mock('../chains/registry', () => ({ + chainModule: (chain: string) => (chain === 'near' ? state.module : undefined), +})) +vi.mock('../agents/market-context', () => ({ + buildMarketContextAsync: async () => ({ + prices: { XLM: 0.1, USDC: 1 }, + venues: [], + routes: [], + }), + quoteRoutes: async () => [], +})) +vi.mock('../swap/limit-price', () => ({ fetchOrderBookTop: async () => undefined })) + +const action = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } + +function nearModule(quote: unknown, price: number | null = 4) { + return { + chain: 'near', + tokens: [ + { symbol: 'NEAR', id: 'wrap.near', decimals: 24 }, + { symbol: 'USDC', id: 'usdc.near', decimals: 6 }, + ], + venues: [{ id: 'ref', name: 'Ref', kinds: ['swap'], quote }], + defaultVenue: {}, + priceUsd: async () => price ?? undefined, + } +} + +const post = (body: unknown): Request => + new Request('http://localhost/api/agents/compete', { + method: 'POST', + body: JSON.stringify(body), + }) + +async function frames(res: Response): Promise[]> { + const text = await res.text() + return text + .split('\n\n') + .map((chunk) => chunk.replace(/^data: /, '').trim()) + .filter((chunk) => chunk.startsWith('{')) + .map((chunk) => JSON.parse(chunk)) +} + +beforeEach(() => { + state.db = fakeAnalyticsDb() + state.headers = {} + delete (globalThis as { intentAnalyticsSchema?: unknown }).intentAnalyticsSchema + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'testnet') + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORKS', '') + state.propose = vi.fn(async () => ({ ok: false, error: 'boom' })) + state.roster = [ + { + key: 'halcyon', + name: 'Halcyon', + gradient: 'a', + model: 'm', + brain: { propose: state.propose }, + }, + ] + state.module = nearModule(async () => ({ receive: { amount: '19.9', symbol: 'USDC' } })) + enforceRateLimit.mockClear() +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) + +describe('POST /api/agents/compete on a chain', () => { + it('starts a race for a chain action on near', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ text: 'swap 5 NEAR to USDC', chain: 'near', account: 'a.near', chainAction: action }) + ) + ) + expect(out[0]?.type).toBe('competition:started') + expect(out[0]?.routeCount).toBe(1) + expect(state.propose).toHaveBeenCalledTimes(1) + const given = (state.propose as any).mock.calls[0][0] + expect(given.market.routes).toHaveLength(1) + expect(given.market.routes[0]).toMatchObject({ id: 'ref-1', source: 'Ref' }) + expect(given.market.venues).toEqual([{ id: 'ref', name: 'Ref', category: 'dex' }]) + }) + + it('still refuses near without a chain action', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames(await POST(post({ text: 'swap 5 NEAR to USDC', chain: 'near' }))) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'chain_unsupported' }) + expect(state.propose).not.toHaveBeenCalled() + }) + + it('keeps the old path for stellar', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar', chainAction: action })) + ) + expect(out[0]?.type).toBe('competition:started') + const given = (state.propose as any).mock.calls[0][0] + expect(out[0]?.routeCount).toBeUndefined() + expect(given.intent.input).toMatchObject({ tokenIn: 'XLM', tokenOut: 'USDC' }) + }) + + it('refuses an unknown token without calling an agent', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'swap 5 NEAR to SCAM', + chain: 'near', + account: 'a.near', + chainAction: { ...action, tokenOut: 'SCAM' }, + }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'action_refused' }) + expect(out[0]?.message).toContain("I can't confirm SCAM as a verified token") + expect(state.propose).not.toHaveBeenCalled() + }) + + it('ends in an error, calling no agent, when every quote fails', async () => { + state.module = nearModule(async () => { + throw new Error('down') + }) + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ text: 'swap 5 NEAR to USDC', chain: 'near', account: 'a.near', chainAction: action }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'chain_unsupported' }) + expect(out[0]?.message).toContain('Nothing was signed or sent') + expect(state.propose).not.toHaveBeenCalled() + }) + + describe('with no venue on the chain for what was asked', () => { + const noVenue = (venues: unknown[]) => ({ ...(nearModule(vi.fn()) as object), venues }) + + it('refuses in one sentence, so nothing races and nothing falls back', async () => { + const quote = vi.fn() + state.module = noVenue([{ id: 'ref', name: 'Ref', kinds: ['swap'], quote }]) + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'supply 5 NEAR', + chain: 'near', + account: 'a.near', + chainAction: { kind: 'supply', amount: '5', tokenIn: 'NEAR' }, + }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ + type: 'competition:error', + code: 'action_refused', + message: 'Nothing here can supply on this chain yet, so nothing was signed or sent.', + }) + expect( + needsDirectRoute({ + phase: 'decided', + winner: null, + anyAnswered: false, + error: out[0] as never, + }) + ).toBe(false) + expect(quote).not.toHaveBeenCalled() + expect(state.propose).not.toHaveBeenCalled() + }) + + it('says so before looking up a price, as a chain with no venues has no reason to', async () => { + const priceUsd = vi.fn(async () => undefined) + state.module = { ...noVenue([]), priceUsd } + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST(post({ text: 'swap', chain: 'near', account: 'a.near', chainAction: action })) + ) + expect(out[0]?.message).toBe( + 'Nothing here can swap on this chain yet, so nothing was signed or sent.' + ) + expect(priceUsd).not.toHaveBeenCalled() + }) + }) + + describe('with the agents offline', () => { + const LAST_SENTENCE = 'Nothing can be executed until they are.' + + it('does not say nothing can run where the direct route is built next', async () => { + state.roster = undefined + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST(post({ text: 'swap', chain: 'near', account: 'a.near', chainAction: action })) + ) + expect(out[0]).toMatchObject({ + code: 'agents_offline', + message: 'The agents are not online right now, so nothing was proposed.', + }) + }) + + it('says what it always said on stellar', async () => { + state.roster = undefined + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames(await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar' }))) + expect(out[0]?.message).toBe( + `The agents are not online right now, so nothing was proposed. ${LAST_SENTENCE}` + ) + }) + }) + + it('rate-limits by an account of 66 characters', async () => { + const account = 'a'.repeat(66) + const { POST } = await import('../../app/api/agents/compete/route') + await (await POST(post({ text: 'swap', chain: 'near', account }))).text() + expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', account) + }) + + describe('refuses an amount before any venue or agent is called', () => { + const cases: [string, string, number | null][] = [ + ['a non-numeric amount', 'lots', 4], + ['a zero amount', '0', 4], + ['a negative amount', '-3', 4], + ['an amount over the trade cap', '1000000000', 4], + ['a token with no live price', '5', null], + ] + it.each(cases)('%s', async (_name, amount, price) => { + const quote = vi.fn(async () => ({ receive: { amount: '1', symbol: 'USDC' } })) + state.module = nearModule(quote, price) + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'swap NEAR to USDC', + chain: 'near', + account: 'a.near', + chainAction: { ...action, amount }, + }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'action_refused' }) + expect(out[0]?.message).toContain('Nothing was signed or sent') + expect(quote).not.toHaveBeenCalled() + expect(state.propose).not.toHaveBeenCalled() + }) + }) +}) + +const raceInput = (chain?: string) => ({ + id: '22222222-2222-4222-8222-222222222222', + network: 'mainnet' as const, + ...(chain === undefined ? {} : { chain }), + startedAt: new Date(Date.now() - 60_000), + durationMs: 1000, + intent: { type: 'swap', tokenIn: 'NEAR', tokenOut: 'USDC', sizeUsd: 10 }, + attempts: [{ agent: 'halcyon', model: 'm', ok: true as const, latencyMs: 900 }], + scores: { halcyon: 90 }, + winner: 'halcyon' as string | null, + unanimous: true as boolean | null, +}) + +describe('a race records the chain it ran on', () => { + it('writes the chain, and stellar when none is given', async () => { + const { logRace } = await import('../server/analytics') + await logRace(raceInput('near')) + await logRace({ ...raceInput(), id: '33333333-3333-4333-8333-333333333333' }) + expect(state.db.races.map((r) => r.chain)).toEqual(['near', 'stellar']) + }) + + it('is written by the compete route for a chain action, and for stellar', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + await ( + await POST( + post({ text: 'swap 5 NEAR to USDC', chain: 'near', account: 'a.near', chainAction: action }) + ) + ).text() + await (await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar' }))).text() + expect(state.db.races.map((r) => [r.chain, r.network])).toEqual([ + ['near', 'mainnet'], + ['stellar', 'testnet'], + ]) + }) +}) + +describe('GET /api/agents/history', () => { + const stellar = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7)) + const accounts: [string, string][] = [ + ['stellar', stellar], + ['solana', 'So11111111111111111111111111111111111111112'], + ['sui', `0x${'a'.repeat(64)}`], + ['near', 'alice.near'], + ['evm', `0x${'b'.repeat(40)}`], + ] + const get = (account: string | null): Request => + new Request( + `http://localhost/api/agents/history${account === null ? '' : `?account=${encodeURIComponent(account)}`}` + ) + + async function seedExecution(account: string, network: 'mainnet' | 'testnet'): Promise { + const { logRace, getAnalyticsRepo } = await import('../server/analytics') + await logRace({ ...raceInput(), network }) + await ( + await getAnalyticsRepo() + ).recordExecution({ + network, + account, + kind: 'swap', + feeSponsored: false, + ok: true, + hash: 'HASH', + raceId: raceInput().id, + }) + } + + it.each(accounts)('answers a %s account its own races', async (name, account) => { + await seedExecution(account, name === 'stellar' ? 'testnet' : 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const res = await GET(get(account)) + expect(res.status).toBe(200) + const body = (await res.json()) as { entries: { executedHash: string }[] } + expect(body.entries.map((e) => e.executedHash)).toEqual(['HASH']) + }) + + it('says which chain each race ran on, so one evm address can tell its chains apart', async () => { + const account = `0x${'c'.repeat(40)}` + const { logRace, getAnalyticsRepo } = await import('../server/analytics') + const repo = await getAnalyticsRepo() + const races = [ + ['avalanche', '44444444-4444-4444-8444-444444444444'], + ['monad', '55555555-5555-4555-8555-555555555555'], + ] as const + for (const [chain, id] of races) { + await logRace({ ...raceInput(chain), id }) + await repo.recordExecution({ + network: 'mainnet', + account, + kind: chain, + feeSponsored: false, + ok: true, + hash: `HASH-${chain}`, + raceId: id, + }) + } + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get(account))).json()) as { + entries: { executedHash: string; chain: string }[] + } + expect(body.entries.map((e) => [e.executedHash, e.chain]).sort()).toEqual([ + ['HASH-avalanche', 'avalanche'], + ['HASH-monad', 'monad'], + ]) + }) + + it.each([ + ['a mixed-case read of a lower-case row', `0x${'ab'.repeat(20)}`, `0x${'Ab'.repeat(20)}`], + ['a lower-case read of a mixed-case row', `0x${'Ab'.repeat(20)}`, `0x${'ab'.repeat(20)}`], + ])('finds an evm account by %s', async (_name, logged, asked) => { + await seedExecution(logged, 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get(asked))).json()) as { entries: unknown[] } + expect(body.entries).toHaveLength(1) + }) + + it('keeps a case-sensitive account exact', async () => { + await seedExecution('Alice.near', 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get('alice.near'))).json()) as { entries: unknown[] } + expect(body.entries).toEqual([]) + }) + + it('never answers another account the races of this one', async () => { + await seedExecution('alice.near', 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get('bob.near'))).json()) as { entries: unknown[] } + expect(body.entries).toEqual([]) + }) + + it.each([ + ['no account', null], + ['text that is no address', 'not an address'], + ['a stellar address with a bad checksum', `G${'A'.repeat(55)}`], + ])('answers invalid_account for %s', async (_name, account) => { + const { GET } = await import('../../app/api/agents/history/route') + const res = await GET(get(account)) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'invalid_account' }) + }) +}) + +describe('POST /api/chain/near/submit and the race it came from', () => { + const RACE = raceInput().id + const submit = vi.fn(async () => ({ ok: true, hash: 'H1' })) + + const send = async (): Promise => { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + return POST( + new Request('http://localhost/api/chain/near/submit', { + method: 'POST', + body: JSON.stringify({ + account: 'alice.near', + venueId: 'ref', + signed: { family: 'near', hashes: ['h1'] }, + }), + }), + { params: Promise.resolve({ chain: 'near' }) } + ) + } + + beforeEach(() => { + state.module = { + chain: 'near', + venues: [{ id: 'ref', assertSafe: () => undefined }], + submit, + } + }) + + async function seedRace(chain: string): Promise { + const { logRace } = await import('../server/analytics') + await logRace(raceInput(chain)) + } + + it('links the execution to the race it carries', async () => { + await seedRace('near') + state.headers = { 'x-intent-race': RACE } + const res = await send() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) + expect(state.db.executions.map((e) => [e.kind, e.race_id])).toEqual([['near', RACE]]) + }) + + it('does not link a race that ran on another chain', async () => { + await seedRace('solana') + state.headers = { 'x-intent-race': RACE } + expect((await send()).status).toBe(200) + expect(state.db.executions.map((e) => e.race_id)).toEqual([null]) + }) + + it.each([ + ['no race id', undefined], + ['a malformed race id', "x'; drop table agent_races"], + ])('answers the same, with no link, for %s', async (_name, header) => { + await seedRace('near') + state.headers = header === undefined ? {} : { 'x-intent-race': header } + const res = await send() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) + expect(state.db.executions.map((e) => e.race_id)).toEqual([null]) + }) + + it('answers as it would without a database when linking fails', async () => { + state.db.down = new Error('connection refused') + state.headers = { 'x-intent-race': RACE } + const res = await send() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) + }) +}) + +describe('the race id on a chain submit, with one network served', () => { + const RACE = raceInput().id + const seen: Headers[] = [] + beforeEach(() => { + seen.length = 0 + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + seen.push(new Headers(init?.headers)) + return new Response(JSON.stringify({ ok: true })) + }) + }) + afterEach(() => { + vi.unstubAllGlobals() + setAttributedRace(undefined) + }) + + it('is sent by the chain submit itself', async () => { + await submitSigned('/api/chain/near/submit', {}, RACE) + expect(seen[0]?.get('x-intent-race')).toBe(RACE) + }) + + it('is absent for an action that came from no race', async () => { + await submitSigned('/api/chain/near/submit', {}) + expect(seen[0]?.get('x-intent-race')).toBeNull() + }) + + it('is credited from the route Review was pressed on', () => { + const prepare = vi.fn() + const route = { kind: 'chain', request: action, venueId: 'ref', venueName: 'Ref' } + const review = (raceId?: string) => + reviewChainRoute({ + route, + asked: action as never, + text: 't', + phase: 'idle', + ...(raceId !== undefined ? { raceId } : {}), + prepare, + refuse: vi.fn(), + }) + review(RACE) + review() + expect(prepare.mock.calls.map((c) => c.length)).toEqual([3, 2]) + expect(prepare.mock.calls[0]?.[2]).toBe(RACE) + }) + + it('is not added by the patched fetch, so an earlier race is never put on a later chain action', async () => { + const sent: Headers[] = [] + const patched = networkFetch( + (async (_u: unknown, init?: RequestInit) => { + sent.push(new Headers(init?.headers)) + return new Response('{}') + }) as typeof fetch, + 'mainnet', + 'https://app.test' + ) + setAttributedRace('earlier-race') + await patched('https://app.test/api/chain/near/submit', { method: 'POST' }) + await patched('https://app.test/api/chain/near/submit', { + method: 'POST', + headers: { 'x-intent-race': RACE }, + }) + await patched('https://app.test/api/swap/submit', { method: 'POST' }) + expect(sent.map((h) => h.get('x-intent-race'))).toEqual([null, RACE, 'earlier-race']) + }) +}) diff --git a/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts new file mode 100644 index 00000000..2413ee56 --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from 'vitest' + +import type { AgentProposalResult, ProposalRequest } from '../agents/brain' +import { buildMessages } from '../agents/brains/openai-compatible' +import { SYSTEM_PROMPT, systemPromptFor } from '../agents/brief' +import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes' +import { buildMarketContext } from '../agents/market-context' +import { measureChainRoute } from '../agents/measure' +import { planPenalties, planScore } from '../agents/plan-scoring' +import { pickWinner, scoreProposals } from '../agents/scoring' +import { applyChainMeasure, isUnmeasuredSwap, settleChainPlan } from '../agents/settle-chain-plan' +import type { ChainActionRequest, ChainModule, ChainToken } from '../chains/types' + +const swap: ChainActionRequest = { kind: 'swap', amount: '100', tokenIn: 'AAA', tokenOut: 'OUT' } + +function quoteOf(over: Partial): ChainRouteQuote { + return { + kind: 'chain', + request: swap, + venueId: 'v', + venueName: 'V', + pay: { amount: '100', symbol: 'AAA' }, + valueInUsd: 5, + ...over, + } +} + +describe('the prompt an agent on another chain is given', () => { + const prompt = systemPromptFor('near') + + it.each(['testnet', 'anchor', 'rest', 'split'])('never mentions %s', (word) => { + expect(prompt).not.toMatch(new RegExp(`\\b${word}\\b`, 'i')) + }) + + it('says the funds are real and the fill is single', () => { + expect(prompt).toMatch(/mainnet/i) + expect(prompt).toMatch(/real funds/i) + }) + + it('is exactly the existing prompt on Stellar', () => { + expect(systemPromptFor('stellar')).toBe(SYSTEM_PROMPT) + }) +}) + +describe('measuring a chain route', () => { + it('ranks $4.97 out above $4.74 out whatever each token’s decimals', async () => { + const decimals = [6, 9, 18, 24] + const outs = [ + { amount: '4.970000', price: 1 }, + { amount: '2.485', price: 2 }, + { amount: '4.74', price: 1 }, + { amount: '0.948', price: 5 }, + ] + const tokens: ChainToken[] = [ + { symbol: 'AAA', id: 'a', decimals: 7 }, + ...decimals.map((d, i) => ({ symbol: `T${i}`, id: `t${i}`, decimals: d })), + ] + const prices: Record = { AAA: 0.05, T0: 1, T1: 2, T2: 1, T3: 5 } + const chainApi = { + tokens, + defaultVenue: {}, + priceUsd: async (t: ChainToken) => prices[t.symbol], + venues: outs.map((o, i) => ({ + id: `v${i}`, + name: `V${i}`, + kinds: ['swap'], + quote: async () => ({ receive: { amount: o.amount, symbol: `T${i}` } }), + })), + } as unknown as ChainModule + + const routes = await quoteChainRoutes(chainApi, swap, 'acct') + const usd = routes.map((r) => measureChainRoute(r.quote as ChainRouteQuote)?.outUsd) + expect(usd[0]).toBeCloseTo(4.97, 6) + expect(usd[1]).toBeCloseTo(4.97, 6) + expect(usd[2]).toBeCloseTo(4.74, 6) + expect(usd[3]).toBeCloseTo(4.74, 6) + expect(usd[0] as number).toBeGreaterThan(usd[2] as number) + }) + + it('takes the venue’s own dollar value over a computed one', () => { + const m = measureChainRoute( + quoteOf({ valueOutUsd: 4.5, receive: { amount: '9', symbol: 'X' } }) + ) + expect(m?.outUsd).toBe(4.5) + expect(m?.vsInPct).toBeCloseTo(10, 6) + }) + + it('is undefined for a swap it cannot price, not zero', () => { + expect(measureChainRoute(quoteOf({ receive: { amount: '9', symbol: 'X' } }))).toBeUndefined() + const { valueInUsd: _unpriced, ...noValueIn } = quoteOf({ valueOutUsd: 4 }) + expect(measureChainRoute(noValueIn)).toBeUndefined() + }) + + it('does not charge a stake or a supply for slippage it cannot have', () => { + const stake = quoteOf({ + request: { kind: 'stake', amount: '100', tokenIn: 'AAA' }, + apyPercent: 4, + receive: { amount: '97', symbol: 'stAAA' }, + }) + expect(measureChainRoute(stake)).toEqual({ outUsd: 5, vsInPct: 0 }) + }) +}) + +describe('settling a chain plan', () => { + it('forces fill and no follow-on, whatever the agent chose', () => { + const proposal = { + agent: 'halcyon', + reasoning: 'x', + projectedAvgPriceUsd: 1, + projectedSlippagePct: 0, + venues: [], + sliceCount: 3, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'rest', + restPriceUsd: 4, + splitPct: 40, + thenAction: 'lend', + thenVenue: 'kamino', + } as unknown as AgentProposalResult + const settled = settleChainPlan(proposal) + expect(settled.executionMode).toBe('fill') + expect(settled.thenAction).toBeUndefined() + expect(settled.thenVenue).toBeUndefined() + expect(settled.restPriceUsd).toBeUndefined() + expect(settled.splitPct).toBeUndefined() + expect(settled.sliceCount).toBe(1) + }) +}) + +describe('the market an agent on Solana is given', () => { + const market = buildMarketContext('solana') + + it('offers only venues the app executes on', async () => { + const { venues } = await import('../venues') + const executes = new Set(venues.filter((v) => v.integration === 'executes').map((v) => v.id)) + expect(market.venues.length).toBeGreaterThan(0) + for (const v of market.venues) expect(executes.has(v.id)).toBe(true) + }) + + it('carries no Stellar tokens or prices', () => { + expect(market.assets ?? []).toEqual([]) + expect(market.prices).toEqual({}) + }) +}) + +describe('the routes as an agent reads them', () => { + function requestFor(routes: ProposalRequest['market']['routes']): ProposalRequest { + return { + agent: 'halcyon', + seat: 0, + chain: 'near', + intent: { + outcome: 'stake 100 NEAR', + escrowUsd: 400, + referencePriceUsd: 4, + targetPriceUsd: 4, + input: { + type: 'market_sell', + tokenIn: 'NEAR', + tokenOut: 'NEAR', + amountIn: '100', + minAmountOut: '0', + deadline: '2026-11-04T00:00:00.000Z', + }, + }, + market: { + asOf: '2026-11-04T00:00:00.000Z', + prices: { NEAR: 4 }, + venues: [{ id: 'meta', name: 'Meta Pool', category: 'staking' }], + volatilityHint: 'normal', + gasHint: 'normal', + ...(routes !== undefined ? { routes } : {}), + }, + } as ProposalRequest + } + + const stakeRoute = { + id: 'meta-1', + source: 'Meta Pool', + sendAmount: '100', + receiveAmount: '', + hops: 1, + executable: true, + quote: quoteOf({ + request: { kind: 'stake', amount: '100', tokenIn: 'NEAR' }, + pay: { amount: '100', symbol: 'NEAR' }, + apyPercent: 6.2, + }), + } + + it('shows a stake by its rate and never as a blank receive', () => { + const text = buildMessages(requestFor([stakeRoute])) + .map((m) => m.content) + .join('\n') + expect(text).toContain('6.2% APY') + expect(text).toContain('send 100 NEAR') + expect(text).not.toMatch(/receive\s*(on|$)/m) + expect(text).not.toMatch(/->\s*receive\s+on/) + }) + + it('does not call the chain a testnet', () => { + const text = buildMessages(requestFor([stakeRoute])) + .map((m) => m.content) + .join('\n') + expect(text).not.toMatch(/testnet/i) + }) + + it('leaves the Stellar prompt as it was', () => { + const req = { ...requestFor(undefined), chain: 'stellar' } + const first = buildMessages(req)[0] + expect(first?.content).toBe(SYSTEM_PROMPT) + }) +}) + +describe('ranking deposits and stakes by rate', () => { + const base = { + agent: 'halcyon', + reasoning: 'x', + projectedAvgPriceUsd: 1, + projectedSlippagePct: 0, + venues: [], + sliceCount: 1, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'fill', + } as unknown as AgentProposalResult + const plan = { + escrowUsd: 400, + referencePriceUsd: 4, + askedToLend: false, + askedToOfframp: false, + apyByRoute: { 'a-1': 6.2, 'b-1': 4.1 }, + } + + it('scores the lower rate below the higher one', () => { + const high = planScore({ ...base, routeId: 'a-1' }, plan) + const low = planScore({ ...base, routeId: 'b-1' }, plan) + expect(high).toBe(100) + expect(low).toBeCloseTo(97.9, 1) + }) + + it('says why in the penalty', () => { + const [p] = planPenalties({ ...base, routeId: 'b-1' }, plan) + expect(p?.reason).toBe('lower_rate') + expect(p?.detail).toContain('4.1%') + expect(p?.detail).toContain('6.2%') + }) + + it('leaves a swap race, which has no rates, untouched', () => { + const { apyByRoute: _ignored, ...swapPlan } = plan + expect(planPenalties({ ...base, routeId: 'b-1' }, swapPlan)).toEqual([]) + }) + + it('puts a route with no rate below the worst route with one', () => { + const rated = planScore({ ...base, routeId: 'b-1' }, plan) + const unrated = planScore({ ...base, routeId: 'kamino-1' }, plan) + expect(unrated).toBeLessThan(rated) + expect(planPenalties({ ...base, routeId: 'kamino-1' }, plan)[0]?.reason).toBe('unrated_route') + }) + + it('never lets a route with no rate be crowned over one that shows a rate', () => { + // Several races, because a tie would be broken by a hash of the race's id. + const winners = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map( + (competitionId) => + scoreProposals( + [ + { ...base, agent: 'halcyon', routeId: 'kamino-1' }, + { ...base, agent: 'meridian', routeId: 'a-1' }, + ] as AgentProposalResult[], + { competitionId, plan } + )[0]?.agent + ) + expect(new Set(winners)).toEqual(new Set(['meridian'])) + }) + + it('keeps that order however far apart the rates are', () => { + const wide = { ...plan, apyByRoute: { 'a-1': 250, 'b-1': 4.1 } } + const rated = planScore({ ...base, routeId: 'b-1' }, wide) + const unrated = planScore({ ...base, routeId: 'kamino-1' }, wide) + expect(unrated).toBeLessThan(rated) + }) + + it('leaves the routes level when none of them has a rate', () => { + const { apyByRoute: _ignored, ...noRates } = plan + expect(planScore({ ...base, routeId: 'kamino-1' }, noRates)).toBe(100) + expect(planPenalties({ ...base, routeId: 'kamino-1' }, noRates)).toEqual([]) + expect(planPenalties({ ...base, routeId: 'kamino-1' }, { ...plan, apyByRoute: {} })).toEqual([]) + }) +}) + +describe('what the panel is shown for a chain route', () => { + const claim = { + agent: 'halcyon', + routeId: 'v-1', + reasoning: 'x', + projectedAvgPriceUsd: 20.1, + projectedSlippagePct: 0, + venues: [], + sliceCount: 1, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'fill', + } as unknown as AgentProposalResult + + it('replaces a token ratio with the dollars paid per unit received', () => { + const q = quoteOf({ valueOutUsd: 4.97, receive: { amount: '4.97', symbol: 'USDC' } }) + const shown = applyChainMeasure(claim, q) + expect(shown.projectedAvgPriceUsd).toBeCloseTo(5 / 4.97, 6) + expect(shown.projectedSlippagePct).toBeCloseTo(0.6, 2) + }) + + it('shows the price of the token paid in for a stake', () => { + const q = quoteOf({ request: { kind: 'stake', amount: '100', tokenIn: 'AAA' }, apyPercent: 5 }) + const shown = applyChainMeasure(claim, q) + expect(shown.projectedAvgPriceUsd).toBeCloseTo(0.05, 6) + expect(shown.projectedSlippagePct).toBe(0) + }) + + it('claims nothing for a swap it cannot price', () => { + const q = quoteOf({ receive: { amount: '4.97', symbol: 'USDC' } }) + const shown = applyChainMeasure({ ...claim, projectedSlippagePct: 4 }, q) + expect(shown.projectedAvgPriceUsd).toBe(0) + expect(shown.projectedSlippagePct).toBe(0) + }) +}) + +describe('a swap route that could not be priced', () => { + const agent = (key: string, routeId: string, slippage: number): AgentProposalResult => + ({ + agent: key, + routeId, + reasoning: 'x', + projectedAvgPriceUsd: 1, + projectedSlippagePct: slippage, + venues: [], + sliceCount: 1, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'fill', + }) as unknown as AgentProposalResult + const plan = { + escrowUsd: 5, + referencePriceUsd: 0.05, + askedToLend: false, + askedToOfframp: false, + unmeasuredRoutes: ['unpriced-1'], + } + + it('is a swap with no dollar value out', () => { + expect(isUnmeasuredSwap(quoteOf({ receive: { amount: '4', symbol: 'X' } }))).toBe(true) + expect(isUnmeasuredSwap(quoteOf({ valueOutUsd: 4 }))).toBe(false) + }) + + it('loses to a priced route that really lost value', () => { + const scored = scoreProposals( + [agent('halcyon', 'unpriced-1', 0), agent('meridian', 'priced-1', 3)], + { competitionId: 'c', plan } + ) + expect(scored[0]?.agent).toBe('meridian') + expect(scored[1]?.penalties[0]?.reason).toBe('unpriced_route') + }) + + it('still picks a winner when every route is unpriced', () => { + const all = { ...plan, unmeasuredRoutes: ['unpriced-1', 'unpriced-2'] } + const scored = scoreProposals( + [agent('halcyon', 'unpriced-1', 0), agent('meridian', 'unpriced-2', 0)], + { competitionId: 'c', plan: all } + ) + expect(scored).toHaveLength(2) + expect(pickWinner(scored)).not.toBeNull() + }) +}) diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts new file mode 100644 index 00000000..98c64452 --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-chain-race.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + chainFigures, + chainRequestFor, + chainReviewClosed, + chainSigning, + isChainRoute, + needsDirectRoute, + recommendationLine, + reviewChainRoute, + routeForSwap, + routeFigures, + routeSource, + selectionHolds, + showDirectNote, + singleRouteNote, +} from '../agents/chain-race' +import type { ChainRouteQuote } from '../agents/chain-routes' +import { describePlan } from '../agents/competition' +import type { ChainActionRequest } from '../chains/types' + +const asked: ChainActionRequest = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } + +const tagged = (source: string | undefined): { source?: string } => + source === undefined ? {} : { source } + +function route(over: Partial = {}, request: ChainActionRequest = asked) { + return { + kind: 'chain', + request, + venueId: 'ref', + venueName: 'Ref', + pay: { amount: request.amount, symbol: request.tokenIn }, + ...over, + } as ChainRouteQuote +} + +describe('chainRequestFor', () => { + it('returns the asked request with only the venue set from the route', () => { + expect(chainRequestFor(route(), { ...asked, venue: 'refin' })).toEqual({ + ...asked, + venue: 'ref', + }) + }) + + it('refuses a route whose amount differs', () => { + expect(chainRequestFor(route({}, { ...asked, amount: '6' }), asked)).toBeUndefined() + }) + + it('refuses a route whose tokenIn differs', () => { + expect(chainRequestFor(route({}, { ...asked, tokenIn: 'USDT' }), asked)).toBeUndefined() + }) + + it('refuses a route whose tokenOut differs', () => { + expect(chainRequestFor(route({}, { ...asked, tokenOut: 'SCAM' }), asked)).toBeUndefined() + }) + + it('refuses a route whose kind differs', () => { + expect(chainRequestFor(route({}, { ...asked, kind: 'stake' }), asked)).toBeUndefined() + }) + + it('refuses a Stellar swap quote', () => { + const stellar = { id: 'x', source: 'SDEX', sendAmount: '5', receiveAmount: '1', path: [] } + expect(chainRequestFor(stellar, asked)).toBeUndefined() + expect(chainRequestFor(undefined, asked)).toBeUndefined() + }) + + it('refuses a route with no venue id', () => { + expect(chainRequestFor(route({ venueId: '' }), asked)).toBeUndefined() + }) +}) + +describe('singleRouteNote', () => { + it('says the exact sentence for one route', () => { + expect(singleRouteNote(1, 'NEAR')).toBe( + "One route available on NEAR. The agents are judging whether it's worth taking." + ) + }) + + it('says nothing otherwise', () => { + expect(singleRouteNote(2, 'NEAR')).toBeUndefined() + expect(singleRouteNote(0, 'NEAR')).toBeUndefined() + expect(singleRouteNote(undefined, 'NEAR')).toBeUndefined() + }) +}) + +describe('reviewChainRoute', () => { + const base = () => ({ + route: route(), + asked, + text: 'swap 5 NEAR to USDC', + phase: 'idle' as const, + prepare: vi.fn(), + refuse: vi.fn(), + }) + + it('prepares the asked request with the route venue', () => { + const args = base() + expect(reviewChainRoute(args)).toBe('prepared') + expect(args.prepare).toHaveBeenCalledWith({ ...asked, venue: 'ref' }, args.text) + }) + + it('does nothing while a prepare is under way', () => { + const args = { ...base(), phase: 'building' as const } + expect(reviewChainRoute(args)).toBe('ignored') + expect(args.prepare).not.toHaveBeenCalled() + expect(args.refuse).not.toHaveBeenCalled() + }) + + it('may replace a built, unsigned card with another venue', () => { + const args = { ...base(), phase: 'review' as const, preparedVenueId: 'other' } + expect(reviewChainRoute(args)).toBe('prepared') + expect(args.prepare).toHaveBeenCalledTimes(1) + }) + + it('builds nothing for the venue already on the card, so its deposit address stays', () => { + const args = { ...base(), phase: 'review' as const, preparedVenueId: 'ref' } + expect(reviewChainRoute(args)).toBe('ignored') + expect(args.prepare).not.toHaveBeenCalled() + expect(args.refuse).not.toHaveBeenCalled() + }) + + it('builds again for the same venue once the card has failed', () => { + const args = { ...base(), phase: 'failed' as const, preparedVenueId: 'ref' } + expect(reviewChainRoute(args)).toBe('prepared') + }) + + it.each(['signing', 'submitting', 'settled', 'pending'] as const)( + 'does nothing once %s', + (phase) => { + const args = { ...base(), phase } + expect(reviewChainRoute(args)).toBe('ignored') + expect(args.prepare).not.toHaveBeenCalled() + } + ) + + it('refuses in plain words when the route is not what was asked', () => { + const args = { ...base(), route: route({}, { ...asked, amount: '9' }) } + expect(reviewChainRoute(args)).toBe('refused') + expect(args.prepare).not.toHaveBeenCalled() + expect(args.refuse).toHaveBeenCalledWith( + expect.stringContaining('Nothing was signed'), + args.text + ) + }) +}) + +describe('needsDirectRoute', () => { + const ended = { phase: 'decided' as const, winner: null, anyAnswered: false } + + it('falls back when the race ends in an offline or unsupported error', () => { + expect(needsDirectRoute({ ...ended, error: { code: 'agents_offline' } })).toBe(true) + expect(needsDirectRoute({ ...ended, error: { code: 'chain_unsupported' } })).toBe(true) + expect(needsDirectRoute({ ...ended, error: { code: 'no_agent_answered' } })).toBe(true) + }) + + it('does not fall back from a refusal the user must read', () => { + expect(needsDirectRoute({ ...ended, error: { code: 'action_refused' } })).toBe(false) + }) + + it('does not fall back while the race is running or after a winner', () => { + expect( + needsDirectRoute({ ...ended, phase: 'competing', error: { code: 'agents_offline' } }) + ).toBe(false) + expect(needsDirectRoute({ phase: 'decided', winner: 'a', anyAnswered: true })).toBe(false) + }) + + it('falls back when the stream ended with nobody answering and no error', () => { + expect(needsDirectRoute(ended)).toBe(true) + }) +}) + +describe('chainFigures', () => { + it('says the price is unknown for a swap with no measured value', () => { + expect(chainFigures(route())).toEqual({ headline: 'Price unknown' }) + }) + + it('leaves a priced swap to the usual display', () => { + expect(chainFigures(route({ valueInUsd: 20, valueOutUsd: 19.9 }))).toBeUndefined() + }) + + it('shows the rate for a supply that has one', () => { + const supply: ChainActionRequest = { kind: 'supply', amount: '5', tokenIn: 'USDC' } + expect(chainFigures(route({ apyPercent: 4.25 }, supply))).toEqual({ headline: '4.25% APY' }) + }) + + it('says the rate is not available otherwise', () => { + const stake: ChainActionRequest = { kind: 'stake', amount: '5', tokenIn: 'NEAR' } + expect(chainFigures(route({}, stake))).toEqual({ headline: 'Rate not available' }) + }) + + it('does not touch a Stellar route', () => { + expect(chainFigures({ id: 'x', source: 'SDEX' })).toBeUndefined() + expect(chainFigures(undefined)).toBeUndefined() + }) +}) + +describe('routeForSwap', () => { + it('keeps every chain route away from the Stellar swap hook', () => { + expect(routeForSwap(route())).toBeUndefined() + expect(routeForSwap({ kind: 'chain' })).toBeUndefined() + expect(isChainRoute({ kind: 'chain', venueId: 5 })).toBe(true) + }) + + it('passes a Stellar quote and nothing through unchanged', () => { + const stellar = { id: 'x', sendAmount: '5000000' } + expect(routeForSwap(stellar)).toBe(stellar) + expect(routeForSwap(undefined)).toBeUndefined() + }) +}) + +describe('chain card phases', () => { + it('closes Review once a signature is out, given, or the outcome is unknown', () => { + expect(chainReviewClosed('signing', false)).toBe(true) + expect(chainReviewClosed('submitting', false)).toBe(true) + expect(chainReviewClosed('settled', false)).toBe(true) + expect(chainReviewClosed('pending', false)).toBe(true) + expect(chainReviewClosed('failed', true)).toBe(true) + expect(chainReviewClosed('review', false)).toBe(false) + expect(chainReviewClosed('failed', false)).toBe(false) + expect(chainReviewClosed('idle', false)).toBe(false) + }) + + it('knows when a signature is in flight', () => { + expect(chainSigning('signing')).toBe(true) + expect(chainSigning('submitting')).toBe(true) + expect(chainSigning('review')).toBe(false) + }) + + it('shows the direct-route note only while nothing is signed', () => { + expect(showDirectNote(true, 'building')).toBe(true) + expect(showDirectNote(true, 'review')).toBe(true) + for (const p of ['idle', 'signing', 'submitting', 'settled', 'pending', 'failed'] as const) { + expect(showDirectNote(true, p)).toBe(false) + } + expect(showDirectNote(false, 'review')).toBe(false) + }) + + it('drops the selected look after a failed or empty card', () => { + expect(selectionHolds('failed')).toBe(false) + expect(selectionHolds('idle')).toBe(false) + expect(selectionHolds('building')).toBe(true) + expect(selectionHolds('review')).toBe(true) + expect(selectionHolds('settled')).toBe(true) + }) +}) + +describe('reviewChainRoute after a signature', () => { + const args = (over: object) => ({ + route: route(), + asked, + text: 't', + phase: 'failed' as const, + prepare: vi.fn(), + refuse: vi.fn(), + ...over, + }) + + it('stays closed after a failure that followed a signature', () => { + const a = args({ signed: true }) + expect(reviewChainRoute(a)).toBe('ignored') + expect(a.prepare).not.toHaveBeenCalled() + }) + + it('reopens after a failure before any signature', () => { + expect(reviewChainRoute(args({ signed: false }))).toBe('prepared') + }) + + it('does not wipe a built card for an agent that chose no route', () => { + const a = args({ route: undefined, phase: 'review' }) + expect(reviewChainRoute(a)).toBe('ignored') + expect(a.refuse).not.toHaveBeenCalled() + }) + + it('says the agent chose no route when there is no card to protect', () => { + const a = args({ route: undefined, phase: 'idle' }) + expect(reviewChainRoute(a)).toBe('refused') + expect(a.refuse).toHaveBeenCalledWith(expect.stringContaining("didn't choose a route"), 't') + }) +}) + +describe('routeSource', () => { + it('names the venue of a chain route, which the plan tag then shows', () => { + const jupiter = routeSource(route({ venueName: 'Jupiter' })) + const oneClick = routeSource(route({ venueName: 'NEAR Intents 1Click' })) + expect(describePlan({ executionMode: 'fill', ...tagged(jupiter) })).toBe( + 'fills now via Jupiter' + ) + expect(describePlan({ executionMode: 'fill', ...tagged(oneClick) })).toBe( + 'fills now via NEAR Intents 1Click' + ) + }) + + it('keeps the source of a Stellar quote', () => { + expect(routeSource({ id: 'x', source: 'SDEX' })).toBe('SDEX') + }) + + it('has nothing to say with no route', () => { + expect(routeSource(undefined)).toBeUndefined() + expect(routeSource({ source: 5 })).toBeUndefined() + }) +}) + +describe('recommendationLine', () => { + it('says what Stellar and a priced swap always said', () => { + expect(recommendationLine('Halcyon', undefined)).toBe( + 'Recommended: Halcyon, best measured fill. You pick who executes.' + ) + }) + + it('gives a supply or a stake its rate, and claims no fill', () => { + expect(recommendationLine('Halcyon', { headline: '5.20% APY' })).toBe( + 'Recommended: Halcyon, 5.20% APY. You pick who executes.' + ) + expect(recommendationLine('Halcyon', { headline: 'Rate not available' })).toBe( + 'Recommended: Halcyon, rate not available. You pick who executes.' + ) + }) + + it('claims no measured fill for a swap nobody could price', () => { + expect(recommendationLine('Halcyon', { headline: 'Price unknown' })).toBe( + 'Recommended: Halcyon, price unknown. You pick who executes.' + ) + }) +}) + +describe('routeFigures', () => { + it('shows an unknown price or rate for a chain proposal with no route', () => { + expect(routeFigures(undefined, asked)).toEqual({ headline: 'Price unknown' }) + expect(routeFigures(undefined, { kind: 'supply', amount: '1', tokenIn: 'USDC' })).toEqual({ + headline: 'Rate not available', + }) + }) + + it('changes nothing for Stellar, which asks for no chain request', () => { + expect(routeFigures(undefined, undefined)).toBeUndefined() + expect(routeFigures({ id: 'x' }, undefined)).toBeUndefined() + }) + + it('formats the rate to two places', () => { + const supply: ChainActionRequest = { kind: 'supply', amount: '5', tokenIn: 'USDC' } + expect(chainFigures(route({ apyPercent: 4.2 }, supply))).toEqual({ headline: '4.20% APY' }) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index fccc6c45..201e7582 100644 --- a/apps/dapp/lib/__tests__/chain-action.test.ts +++ b/apps/dapp/lib/__tests__/chain-action.test.ts @@ -28,6 +28,25 @@ describe('parseChainAction', () => { }) }) + it('writes a bare decimal the way the build route and base units accept it', () => { + const amount = parseChainAction('swap .5 USDC to SOL')?.amount as string + expect(amount).toBe('0.5') + expect(toBaseUnits(amount, 6)).toBe('500000') + expect(parseChainAction('supply $.25 USDC')?.amount).toBe('0.25') + expect(parseChainAction('stake .1 NEAR')?.amount).toBe('0.1') + }) + + it.each([ + ['5', '5'], + ['0.5', '0.5'], + ['05', '05'], + ['1,000', '1000'], + ['1,000.5', '1000.5'], + ['$20', '20'], + ])('keeps %s as it read before', (typed, amount) => { + expect(parseChainAction(`swap ${typed} USDC to SOL`)?.amount).toBe(amount) + }) + it('reads a supply and a stake', () => { expect(parseChainAction('supply 100 USDC on Kamino')).toMatchObject({ kind: 'supply', diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts new file mode 100644 index 00000000..ee5d18e9 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -0,0 +1,449 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { evmExplorerUrl } from '../chains/evm/tokens' +import { explorerUrl as nearExplorerUrl } from '../chains/near/tokens' +import { + accountMismatch, + afterSigning, + afterSubmitting, + ATTEMPT_WINDOW_MS, + attemptNotice, + earlierAttempt, + latchBeforeSign, + noteSubmitAnswer, + noteWalletAnswer, + recordAttempt, + submitSigned, + type AfterSigning, + type AttemptStorage, +} from '../chains/signing-flow' +import { explorerUrl as solanaExplorerUrl } from '../chains/solana/submit' +import { panelErrorMessage, reviewChainRoute } from '../agents/chain-race' +import type { ChainActionRequest } from '../chains/types' +import { CHECK_WALLET } from '../weave/cashout-view' + +const asked: ChainActionRequest = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } +const route = { + kind: 'chain', + request: asked, + venueId: 'ref', + venueName: 'Ref', + pay: { amount: '5', symbol: 'NEAR' }, +} + +function review(phase: Parameters[0]['phase'], signed: boolean) { + const prepare = vi.fn() + const outcome = reviewChainRoute({ + route, + asked, + text: 't', + phase, + signed, + prepare, + refuse: vi.fn(), + }) + return { outcome, prepare } +} + +describe('the latch before the wallet is called', () => { + it('is set for wallets that send for themselves', () => { + expect(latchBeforeSign('near')).toBe(true) + expect(latchBeforeSign('evm')).toBe(true) + }) + + it('is not set for wallets that return signed bytes', () => { + expect(latchBeforeSign('solana')).toBe(false) + expect(latchBeforeSign('sui')).toBe(false) + }) +}) + +describe('afterSigning', () => { + it.each(['wallet_error', 'rejected'] as const)( + 'a NEAR %s asks the user to check the wallet and never returns to review', + (reason) => { + const step = afterSigning({ ok: false, reason }, 'near') + expect(step.next).toBe('unknown') + expect((step as { error: string }).error).toContain(CHECK_WALLET) + expect((step as { error: string }).error).toContain('new message') + } + ) + + it('lets a signed-bytes wallet decline back to review', () => { + expect(afterSigning({ ok: false, reason: 'rejected' }, 'solana')).toEqual({ next: 'review' }) + expect(afterSigning({ ok: false, reason: 'rejected' }, 'sui')).toEqual({ next: 'review' }) + }) + + it('fails a signed-bytes wallet error plainly', () => { + expect(afterSigning({ ok: false, reason: 'wallet_error', detail: 'no' }, 'sui')).toEqual({ + next: 'failed', + error: 'no', + }) + }) + + it('submits what a wallet signed', () => { + const signed = { family: 'solana', signedTransaction: 'x' } as const + expect(afterSigning({ ok: true, signed }, 'solana')).toEqual({ next: 'submit', signed }) + }) +}) + +describe('afterSubmitting', () => { + it('keeps the latch on success and on an unknown outcome', () => { + expect(afterSubmitting({ ok: true, hash: 'h', explorerUrl: 'u' }, 'solana')).toMatchObject({ + phase: 'settled', + latched: true, + }) + expect( + afterSubmitting({ ok: false, pending: true, error: 'maybe sent it' }, 'sui') + ).toMatchObject({ phase: 'pending', latched: true }) + expect(afterSubmitting({ ok: false, error: 'odd' }, 'near')).toMatchObject({ + phase: 'pending', + latched: true, + }) + expect(afterSubmitting(undefined, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('releases the latch only for a clean refusal on a signed-bytes wallet', () => { + expect( + afterSubmitting({ ok: false, error: 'The quote moved too far.' }, 'solana') + ).toMatchObject({ phase: 'failed', latched: false }) + }) +}) + +describe('the two double-spend paths', () => { + it('Dismiss then Review after a pending result stays closed', () => { + // Dismiss resets the card to idle; the latch belongs to the race and survives. + const done = afterSubmitting({ ok: false, pending: true, error: 'maybe sent it' }, 'sui') + expect(done.latched).toBe(true) + const { outcome, prepare } = review('idle', done.latched) + expect(outcome).toBe('ignored') + expect(prepare).not.toHaveBeenCalled() + }) + + it('a NEAR wallet_error then Review stays closed, in every phase it can land in', () => { + const latched = latchBeforeSign('near') + expect(afterSigning({ ok: false, reason: 'wallet_error' }, 'near').next).toBe('unknown') + for (const phase of ['pending', 'failed', 'idle'] as const) { + const { outcome, prepare } = review(phase, latched) + expect(outcome).toBe('ignored') + expect(prepare).not.toHaveBeenCalled() + } + }) + + it('a decline on Solana before anything is sent can still be reviewed again', () => { + expect(afterSigning({ ok: false, reason: 'rejected' }, 'solana').next).toBe('review') + expect(review('review', latchBeforeSign('solana')).outcome).toBe('prepared') + }) +}) + +describe('accountMismatch on the review card', () => { + it('refuses a wallet other than the one the card was built for', () => { + expect(accountMismatch('b.near', 'a.near')).toContain('Nothing was sent') + expect(accountMismatch(undefined, 'a.near')).toBeDefined() + }) + + it('accepts the same account', () => { + expect(accountMismatch('a.near', 'a.near')).toBeUndefined() + }) +}) + +describe('unknown outcomes keep the latch and Review closed', () => { + // The body the chain submit route sends from its catch-all. + const route502 = { + ok: false, + pending: true, + error: + "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", + } + const notConfirmed = { + ok: false, + pending: true, + error: + 'This was sent, but not confirmed yet. Check https://solscan.io/tx/x before trying again.', + } + const unknownJupiterCode = { + ok: false, + pending: true, + error: + 'Jupiter hasn’t confirmed this swap, and it may still have gone through, so don’t send it again. Check your wallet’s activity first, and get a fresh quote only if nothing changed.', + } + + it.each([ + ['the route catch-all', route502], + ['a send not confirmed yet', notConfirmed], + ['an unknown Jupiter code', unknownJupiterCode], + ])('%s', (_name, body) => { + for (const family of ['solana', 'sui'] as const) { + const done = afterSubmitting(body, family) + expect(done).toMatchObject({ phase: 'pending', latched: true }) + for (const phase of ['pending', 'idle', 'failed'] as const) { + const { outcome, prepare } = review(phase, done.latched) + expect(outcome).toBe('ignored') + expect(prepare).not.toHaveBeenCalled() + } + } + }) +}) + +describe('an answer that can not be read, once a signature has gone to the server', () => { + afterEach(() => vi.unstubAllGlobals()) + + const request = { account: 'a', venueId: 'v' } + + it.each([ + ['the platform killing the function', () => new Response('504', { status: 504 })], + ['an empty answer', () => new Response('', { status: 200 })], + [ + 'the connection dropping', + () => { + throw new TypeError('fetch failed') + }, + ], + ])('holds the latch after %s', async (_name, answer) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => answer()) + ) + const result = await submitSigned('/api/chain/solana/submit', request) + for (const family of ['solana', 'sui'] as const) { + expect(afterSubmitting(result, family)).toMatchObject({ phase: 'pending', latched: true }) + } + }) + + it('posts the body as JSON and hands back a readable answer as it is', async () => { + const answer = { ok: false, error: 'The quote moved too far.' } + const fetchMock = vi.fn(async () => new Response(JSON.stringify(answer), { status: 422 })) + vi.stubGlobal('fetch', fetchMock) + await expect(submitSigned('/api/chain/solana/submit', request)).resolves.toEqual(answer) + expect(fetchMock).toHaveBeenCalledWith('/api/chain/solana/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + }) +}) + +describe('panelErrorMessage', () => { + const said = 'No route could be priced right now. Nothing was signed or sent.' + it('keeps the sentence while nothing may be signed', () => { + expect(panelErrorMessage(said, false)).toBe(said) + }) + it('drops it when it is not the last sentence', () => { + expect( + panelErrorMessage('Not priced. Nothing was signed or sent. Try again later.', true) + ).toBe('Not priced. Try again later.') + }) + it('drops it once something may have been', () => { + expect(panelErrorMessage(said, true)).toBe('No route could be priced right now.') + }) +}) + +describe('the warning that survives a reload', () => { + const NOW = 1_800_000_000_000 + const MINUTE = 60_000 + const asking = { chain: 'monad', account: '0xabc', tokenIn: 'USDC', amount: '5' } + const attempt = { ...asking, at: NOW } + + function fakeStorage(start: string | null = null): AttemptStorage & { value: string | null } { + const store = { + value: start, + getItem: () => store.value, + setItem: (_key: string, value: string) => { + store.value = value + }, + } + return store + } + + const later = (minutes: number) => NOW + minutes * MINUTE + const started = () => { + const storage = fakeStorage() + recordAttempt(storage, attempt) + return storage + } + + describe('matching', () => { + it('finds an attempt on the same chain, account, token and amount', () => { + expect(earlierAttempt(started(), asking, later(3))).toEqual(attempt) + }) + + it.each([ + ['chain', { chain: 'avalanche' }], + ['account', { account: '0xdef' }], + ['token', { tokenIn: 'USDT0' }], + ['amount', { amount: '6' }], + ])('finds nothing for another %s', (_name, change) => { + expect(earlierAttempt(started(), { ...asking, ...change }, later(3))).toBeUndefined() + }) + + it('reads an EVM address, a token and an amount however they were written', () => { + const same = { ...asking, account: '0xABC', tokenIn: 'usdc', amount: '5.0' } + expect(earlierAttempt(started(), same, later(3))).toEqual(attempt) + }) + + it('keeps case in an address where case is part of it', () => { + const storage = fakeStorage() + recordAttempt(storage, { ...attempt, chain: 'solana', account: 'SoLaNa' }) + const other = { ...asking, chain: 'solana', account: 'solana' } + expect(earlierAttempt(storage, other, later(3))).toBeUndefined() + }) + + it('gives the newest of two', () => { + const storage = started() + recordAttempt(storage, { ...attempt, at: later(2) }) + expect(earlierAttempt(storage, asking, later(3))?.at).toBe(later(2)) + }) + }) + + describe('expiry', () => { + it('holds for 15 minutes and no longer', () => { + expect(earlierAttempt(started(), asking, NOW + ATTEMPT_WINDOW_MS)).toEqual(attempt) + expect(earlierAttempt(started(), asking, NOW + ATTEMPT_WINDOW_MS + 1)).toBeUndefined() + }) + + it('drops expired attempts the next time one is written', () => { + const storage = started() + recordAttempt(storage, { ...attempt, tokenIn: 'MON', at: later(20) }) + expect(JSON.parse(storage.value as string)).toHaveLength(1) + }) + }) + + describe('broken storage changes nothing', () => { + const throwing: AttemptStorage = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('full') + }, + } + + it.each([ + ['that throws', throwing], + ['that is missing', undefined], + ])('storage %s', (_name, storage) => { + expect(() => recordAttempt(storage, attempt)).not.toThrow() + expect(() => noteWalletAnswer(storage, attempt, undefined)).not.toThrow() + expect(() => + noteSubmitAnswer(storage, attempt, { ok: true, hash: 'h', explorerUrl: 'u' }) + ).not.toThrow() + expect(earlierAttempt(storage, asking, later(3))).toBeUndefined() + }) + + it.each([ + ['text that is not JSON', 'not json'], + ['JSON that is not a list', '{"chain":"monad"}'], + ['a list of the wrong things', '[1,null,{"chain":"monad","at":"soon"}]'], + ])('reads %s as nothing, and writes over it', (_name, saved) => { + const storage = fakeStorage(saved) + expect(earlierAttempt(storage, asking, later(3))).toBeUndefined() + recordAttempt(storage, attempt) + expect(earlierAttempt(storage, asking, later(3))).toEqual(attempt) + }) + }) + + describe('what the wallet answers', () => { + it.each([ + ['a decline on a wallet that only signs', { next: 'review' }], + ['an error before anything was sent', { next: 'failed', error: 'no' }], + ['a wallet that cannot sign here', undefined], + ] as [string, AfterSigning | undefined][])('forgets the attempt after %s', (_name, step) => { + const storage = started() + noteWalletAnswer(storage, attempt, step) + expect(earlierAttempt(storage, asking, later(1))).toBeUndefined() + }) + + it('keeps the attempt when a wallet that sends for itself gives no confirmation', () => { + const storage = started() + noteWalletAnswer(storage, attempt, afterSigning({ ok: false, reason: 'rejected' }, 'evm')) + expect(earlierAttempt(storage, asking, later(1))).toEqual(attempt) + }) + + it('adds the hash a wallet returned', () => { + const storage = started() + noteWalletAnswer(storage, attempt, { + next: 'submit', + signed: { family: 'evm', hash: '0xh' }, + }) + expect(earlierAttempt(storage, asking, later(1))?.hash).toBe('0xh') + }) + + it('adds the last of the hashes a NEAR wallet returned, the one its venue follows', () => { + const storage = started() + noteWalletAnswer(storage, attempt, { + next: 'submit', + signed: { family: 'near', hashes: ['first', 'last'] }, + }) + expect(earlierAttempt(storage, asking, later(1))?.hash).toBe('last') + }) + + it('has no hash yet for signed bytes', () => { + const storage = started() + noteWalletAnswer(storage, attempt, { + next: 'submit', + signed: { family: 'solana', signedTransaction: 'x' }, + }) + expect(earlierAttempt(storage, asking, later(1))).toEqual(attempt) + }) + }) + + describe('what the server answers', () => { + it.each([ + ['a clean refusal', { ok: false, error: 'The quote moved too far.' }], + ['a proven revert', { ok: false, error: 'This transfer failed on Monad.' }], + ])('forgets the attempt after %s', (_name, body) => { + const storage = started() + noteSubmitAnswer(storage, attempt, body) + expect(earlierAttempt(storage, asking, later(1))).toBeUndefined() + }) + + it.each([ + ['pending', { ok: false, pending: true, error: 'Still going.' }], + ['no answer at all', undefined], + ['a code from before the venue was reached', { error: 'invalid_body' }], + ])('keeps the attempt after %s', (_name, body) => { + const storage = started() + noteSubmitAnswer(storage, attempt, body) + expect(earlierAttempt(storage, asking, later(1))).toEqual(attempt) + }) + + it('keeps the attempt after success, with the hash, since retyping would pay twice', () => { + const storage = started() + noteSubmitAnswer(storage, attempt, { ok: true, hash: 'h1', explorerUrl: 'u' }) + expect(earlierAttempt(storage, asking, later(1))?.hash).toBe('h1') + }) + + it('forgets only its own attempt', () => { + const storage = started() + const second = { ...attempt, at: later(1) } + recordAttempt(storage, second) + noteSubmitAnswer(storage, second, { ok: false, error: 'The quote moved too far.' }) + expect(earlierAttempt(storage, asking, later(2))).toEqual(attempt) + }) + }) + + describe('what the card says', () => { + it.each([ + [0, 'You started this 1 minute ago, check your wallet first.'], + [1, 'You started this 1 minute ago, check your wallet first.'], + [14, 'You started this 14 minutes ago, check your wallet first.'], + ])('after %i minutes', (minutes, text) => { + expect(attemptNotice(attempt, later(minutes))).toEqual({ text }) + }) + + it.each([ + ['near', nearExplorerUrl('h1')], + ['monad', evmExplorerUrl('monad', 'h1')], + ['avalanche', evmExplorerUrl('avalanche', 'h1')], + ['solana', solanaExplorerUrl('h1')], + ['sui', 'https://suiscan.xyz/mainnet/tx/h1'], + ])('links the hash on %s', (chain, explorerUrl) => { + const notice = attemptNotice({ ...attempt, chain, hash: 'h1' }, later(3)) + expect(notice).toMatchObject({ hash: 'h1', explorerUrl }) + }) + + it('keeps a stored hash inside the path of the explorer', () => { + const notice = attemptNotice({ ...attempt, hash: '../../evil?x=1' }, later(3)) + expect(notice.explorerUrl).toBe(evmExplorerUrl('monad', '..%2F..%2Fevil%3Fx%3D1')) + }) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts b/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts new file mode 100644 index 00000000..b399a1d2 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { afterSubmitting } from '../chains/signing-flow' + +const state = vi.hoisted(() => ({ + submit: undefined as unknown, + assertSafe: undefined as unknown, + submitted: 0, +})) + +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) +vi.mock('../chains/registry', () => ({ + chainModule: () => ({ + chain: 'solana', + submit: (input: unknown) => { + state.submitted += 1 + return (state.submit as (i: unknown) => unknown)(input) + }, + venues: [{ id: 'v', assertSafe: () => (state.assertSafe as () => void)() }], + }), +})) + +const body = { + account: '11111111111111111111111111111111', + venueId: 'v', + signed: { family: 'solana', signedTransaction: 'abc' }, +} + +async function post(): Promise { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + return POST( + new Request('http://localhost/api/chain/solana/submit', { + method: 'POST', + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chain: 'solana' }) } + ) +} + +beforeEach(() => { + state.assertSafe = () => undefined + state.submitted = 0 +}) + +afterEach(() => vi.resetModules()) + +describe('the chain submit route when the outcome is unknown', () => { + it('answers pending (202) from its catch-all, which holds the latch', async () => { + state.submit = async () => { + throw new Error('rpc timed out') + } + const res = await post() + const json = await res.json() + expect(res.status).toBe(202) + expect(json).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('answers pending even if a refusal is thrown once sending has started', async () => { + // Loaded here because the route gets a fresh copy of this module after each reset. + const { ChainActionRefused } = await import('../chains/types') + state.submit = async () => { + throw new ChainActionRefused('Refused after the send.') + } + const res = await post() + const json = await res.json() + expect(res.status).toBe(202) + expect(json).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('still answers 422 for a clean refusal, which releases the latch on a signed-bytes wallet', async () => { + state.submit = async () => ({ ok: false, error: 'The network rejected this transaction.' }) + const res = await post() + const json = await res.json() + expect(res.status).toBe(422) + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + }) +}) + +describe('the chain submit route before anything is sent', () => { + it('answers 422 with the refusal when the signed bytes fail the safety check', async () => { + const { ChainActionRefused } = await import('../chains/types') + state.assertSafe = () => { + throw new ChainActionRefused('This transaction calls a program it should not need.') + } + state.submit = async () => ({ ok: true, hash: 'x', explorerUrl: 'y' }) + const res = await post() + expect(res.status).toBe(422) + await expect(res.json()).resolves.toEqual({ + ok: false, + error: 'This transaction calls a program it should not need.', + }) + expect(state.submitted).toBe(0) + }) + + it('says nothing was sent when the safety check itself breaks', async () => { + state.assertSafe = () => { + throw new TypeError('cannot read the transaction') + } + state.submit = async () => ({ ok: true, hash: 'x', explorerUrl: 'y' }) + const res = await post() + const json = (await res.json()) as { error: string; pending?: boolean } + expect(res.status).toBe(422) + expect(json.pending).toBeUndefined() + expect(json.error).toMatch(/nothing was sent/i) + expect(JSON.stringify(json)).not.toContain('cannot read the transaction') + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + expect(state.submitted).toBe(0) + }) +}) diff --git a/apps/dapp/lib/__tests__/compete-route-log.test.ts b/apps/dapp/lib/__tests__/compete-route-log.test.ts index 6951eb0d..25581f52 100644 --- a/apps/dapp/lib/__tests__/compete-route-log.test.ts +++ b/apps/dapp/lib/__tests__/compete-route-log.test.ts @@ -123,7 +123,8 @@ describe('POST /api/agents/compete', () => { error: 'aborted: timeout', }) for (const a of attempts) expect(a.latencyMs).toBeGreaterThanOrEqual(0) - }) + // The first import of the route pulls in every chain's SDK, which sits right at the default. + }, 30_000) it('records a race that nobody answered, with no winner', async () => { state.roster = [ @@ -167,7 +168,7 @@ describe('POST /api/agents/compete', () => { 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) })) + await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar', account: 'x'.repeat(129) })) ).text() expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', undefined) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts new file mode 100644 index 00000000..7c4b0048 --- /dev/null +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -0,0 +1,1807 @@ +import { custom, encodeFunctionData, parseAbi, type Address, type TransactionReceipt } from 'viem' +import { avalanche, monad } from 'viem/chains' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { singleRouteNote } from '../agents/chain-race' +import { quoteChainRoutes } from '../agents/chain-routes' +import { chainCapabilities } from '../chains/capabilities' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' +import { createEvmDeposit } from '../chains/evm/deposit' +import { buildEvmVenues } from '../chains/evm/module' +import { AVALANCHE_TOKENS, EVM_CHAINS, EVM_NATIVE_ID, MONAD_TOKENS } from '../chains/evm/tokens' +import { clearTokenCache } from '../chains/near/oneclick' +import { oneClickAssetId, oneClickOriginVenue } from '../chains/oneclick-origin' +import { parseChainAction } from '../chains/parse-action' +import { assertKnownTokens, prepareChainAction } from '../chains/prepare' +import { chainModule } from '../chains/registry' +import { afterSubmitting, QUOTE_EXPIRED, quoteExpiredBeforeSigning } from '../chains/signing-flow' +import { + ChainActionRefused, + tokenBySymbol, + type ChainActionRequest, + type ChainModule, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type SubmitResult, +} from '../chains/types' +import { buildMarketContext } from '../agents/market-context' +import { capabilitySheet } from '../orchestrate/capabilities' +import { venueOnChain, venues } from '../venues' +import { CHAIN_DESCRIPTORS } from '@intent/config' + +vi.hoisted(() => { + process.env['AUTH_SECRET'] = 'x'.repeat(48) +}) + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) + +const rpc = vi.hoisted(() => ({ + tokenBalance: (async () => BigInt(0)) as (token: string, account: string) => Promise, + nativeBalance: (async () => BigInt(0)) as (account: string) => Promise, + waitForReceipt: (async () => { + throw new Error('no receipt') + }) as (hash: string) => Promise, + transaction: (async () => ({ value: BigInt(0), input: '0x' })) as ( + hash: string + ) => Promise<{ value: bigint; input: string }>, + reads: [] as string[], +})) + +// The venue reads through this, so a test decides what the chain says. A test that passes a +// transport gets the real client over it. +vi.mock('../chains/evm/client', async (original) => { + const actual = await original() + const fake = { + tokenBalance: (token: string, account: string) => rpc.tokenBalance(token, account), + nativeBalance: (account: string) => rpc.nativeBalance(account), + waitForReceipt: (hash: string) => { + rpc.reads.push(hash) + return rpc.waitForReceipt(hash) + }, + transaction: (hash: string) => rpc.transaction(hash), + } + return { + ...actual, + evmClient: (chain: Parameters[0], transport?: never) => + transport === undefined ? fake : actual.evmClient(chain, transport), + realEvmClient: actual.evmClient, + } +}) + +const wallet = vi.hoisted(() => ({ + account: { status: 'connected', chainId: 143 } as { status: string; chainId: number | undefined }, + calls: [] as unknown[][], +})) + +vi.mock('wagmi/actions', () => ({ + getAccount: () => wallet.account, + switchChain: async (_config: unknown, args: unknown) => { + wallet.calls.push(['switch', args]) + return { id: 143 } + }, + sendTransaction: async (_config: unknown, args: unknown) => { + wallet.calls.push(['send', args]) + return WALLET_HASH + }, +})) +vi.mock('../wagmi.config', () => ({ arcTestnet: { id: 5042002 }, wagmiConfig: {} })) + +const WALLET_HASH = `0x${'c'.repeat(64)}` +const ACCOUNT = '0x1111111111111111111111111111111111111111' +const DEPOSIT = '0x2222222222222222222222222222222222222222' +const OTHER = '0x3333333333333333333333333333333333333333' +const ZERO = '0x0000000000000000000000000000000000000000' +const HASH = `0x${'a'.repeat(64)}` +const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' +const TRANSFER_ABI = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']) + +const token = (list: readonly ChainToken[], symbol: string): ChainToken => + tokenBySymbol(list, symbol) as ChainToken + +const AVAX = token(AVALANCHE_TOKENS, 'AVAX') +const AVAX_USDC = token(AVALANCHE_TOKENS, 'USDC') +const AVAX_USDT = token(AVALANCHE_TOKENS, 'USDT') +const MON = token(MONAD_TOKENS, 'MON') +const MONAD_USDC = token(MONAD_TOKENS, 'USDC') +const USDT0 = token(MONAD_TOKENS, 'USDT0') + +const USD_UNITS = BigInt(25_000_000) +const ONE_AND_A_HALF_AVAX = '1500000000000000000' + +type Json = Record + +interface Priced { + decimals: number + price: number +} + +const ASSETS: Record = { + 'nep245:avax-native': { decimals: 18, price: 20 }, + 'nep245:avax-usdc': { decimals: 6, price: 1 }, + 'nep245:avax-usdt': { decimals: 6, price: 1 }, + 'nep245:monad-native': { decimals: 18, price: 0.04 }, + 'nep245:monad-usdc': { decimals: 6, price: 1 }, + 'nep245:monad-usdt0': { decimals: 6, price: 1 }, +} + +function tokenList(): Json[] { + const priceUpdatedAt = new Date().toISOString() + const entry = ( + assetId: string, + blockchain: string, + symbol: string, + contractAddress?: string + ): Json => ({ + assetId, + blockchain, + symbol, + decimals: ASSETS[assetId]?.decimals ?? 6, + price: ASSETS[assetId]?.price ?? 1, + priceUpdatedAt, + ...(contractAddress !== undefined ? { contractAddress } : {}), + }) + return [ + entry('nep245:avax-native', 'avax', 'AVAX'), + // 1Click writes these in lower case, and the constants carry a checksum. + entry('nep245:avax-usdc', 'avax', 'USDC', AVAX_USDC.id.toLowerCase()), + entry('nep245:avax-usdt', 'avax', 'USDT', AVAX_USDT.id.toLowerCase()), + entry('nep245:avax-fake', 'avax', 'USDC', OTHER), + entry('nep245:monad-native', 'monad', 'MON'), + entry('nep245:monad-usdc', 'monad', 'USDC', MONAD_USDC.id.toLowerCase()), + entry('nep245:monad-usdt0', 'monad', 'USDT0', USDT0.id.toLowerCase()), + ] +} + +interface Sim { + tokens: Json[] + quotes: Json[] + notified: Json[] + statuses: (string | undefined)[] + statusRecipient: string | undefined + depositAddress: string +} + +let sim: Sim + +function goodQuote(body: Json): Json { + const amount = body['amount'] as string + const from = ASSETS[body['originAsset'] as string] as Priced + const to = ASSETS[body['destinationAsset'] as string] as Priced + const micro = (price: number): bigint => BigInt(Math.round(price * 1_000_000)) + const out = + (BigInt(amount) * micro(from.price) * BigInt(10) ** BigInt(to.decimals)) / + (micro(to.price) * BigInt(10) ** BigInt(from.decimals)) + const usd = (Number(amount) / 10 ** from.decimals) * from.price + return { + quote: { + amountIn: amount, + amountInFormatted: 'x', + amountInUsd: String(usd), + minAmountIn: amount, + amountOut: out.toString(), + amountOutFormatted: 'x', + amountOutUsd: String(usd * 0.995), + minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(), + ...(body['dry'] === true ? {} : { depositAddress: sim.depositAddress }), + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + } +} + +function installFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status }) + if (url.endsWith('/v0/tokens')) return json(sim.tokens) + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Json + sim.quotes.push(body) + return json(goodQuote(body), 201) + } + if (url.endsWith('/v0/deposit/submit')) { + sim.notified.push(JSON.parse(init?.body as string) as Json) + return json({ correlationId: 'c' }) + } + if (url.includes('/v0/status')) { + const status = sim.statuses.length > 1 ? sim.statuses.shift() : sim.statuses[0] + return json({ status, quoteResponse: { quoteRequest: { recipient: sim.statusRecipient } } }) + } + throw new Error(`no live call: ${url}`) + }) + ) +} + +const word = (hexDigits: string): string => hexDigits.padStart(64, '0') +const addressWord = (address: string): string => word(address.slice(2).toLowerCase()) + +function transferLog( + tokenAddress: string, + units: bigint, + change: { from?: string; to?: string } = {} +): TransactionReceipt['logs'][number] { + return { + address: tokenAddress, + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: BigInt(1), + data: `0x${word(units.toString(16))}`, + logIndex: 0, + removed: false, + topics: [ + TRANSFER_TOPIC, + `0x${addressWord(change.from ?? ACCOUNT)}`, + `0x${addressWord(change.to ?? DEPOSIT)}`, + ], + transactionHash: HASH, + transactionIndex: 0, + } as unknown as TransactionReceipt['logs'][number] +} + +function receipt(change: Json = {}): Json { + return { status: 'success', from: ACCOUNT, to: DEPOSIT, logs: [], ...change } +} + +const swap = (over: Partial = {}): ChainActionRequest => ({ + kind: 'swap', + amount: '25', + tokenIn: 'USDC', + tokenOut: 'AVAX', + ...over, +}) + +const avaxSwap = (over: Partial = {}): ChainActionRequest => + swap({ tokenIn: 'AVAX', tokenOut: 'USDC', amount: '1.5', ...over }) + +const venueAvalanche = oneClickOriginVenue('avalanche') +const venueMonad = oneClickOriginVenue('monad') + +function submitTo( + prepared: PreparedAction, + venue = venueAvalanche, + change: { hash?: string; account?: string; context?: Record } = {} +): Promise { + return (venue.submit as NonNullable)({ + account: change.account ?? ACCOUNT, + venueId: venue.id, + signed: { family: 'evm', hash: change.hash ?? HASH }, + context: change.context ?? (prepared.context as Record), + }) +} + +function withoutPending(result: SubmitResult): void { + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') +} + +function isPending(result: SubmitResult): void { + expect(result).toMatchObject({ ok: false, pending: true }) +} + +beforeEach(() => { + clearTokenCache() + sim = { + tokens: tokenList(), + quotes: [], + notified: [], + statuses: ['SUCCESS'], + statusRecipient: ACCOUNT, + depositAddress: DEPOSIT, + } + rpc.tokenBalance = async () => BigInt(10) ** BigInt(30) + rpc.nativeBalance = async () => BigInt(10) ** BigInt(30) + rpc.waitForReceipt = async () => { + throw new Error('no receipt was arranged for this test') + } + rpc.transaction = async () => { + throw new Error('no transaction was arranged for this test') + } + rpc.reads = [] + installFetch() +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + wallet.account = { status: 'connected', chainId: 143 } + wallet.calls = [] +}) + +describe('the chain modules', () => { + it('confirms what viem says the chain ids are', () => { + expect(monad.id).toBe(143) + expect(avalanche.id).toBe(43114) + expect(EVM_CHAINS.monad.chain.id).toBe(143) + expect(EVM_CHAINS.avalanche.chain.id).toBe(43114) + }) + + it('finds a module for both, each with its own venue only', () => { + const avalancheModule = chainModule('avalanche') + const monadModule = chainModule('monad') + expect(avalancheModule?.chain).toBe('avalanche') + expect(monadModule?.chain).toBe('monad') + expect(avalancheModule?.venues.map((v) => v.id)).toEqual(['1click-avalanche']) + expect(monadModule?.venues.map((v) => v.id)).toEqual(['1click-monad']) + expect(avalancheModule?.defaultVenue.swap).toBe('1click-avalanche') + expect(monadModule?.defaultVenue.swap).toBe('1click-monad') + expect(chainModule('arc')).toBeUndefined() + }) + + it('lists the tokens the brief names, with the contracts 1Click lists', () => { + expect(AVALANCHE_TOKENS.map((t) => t.symbol)).toEqual(['AVAX', 'USDC', 'USDT']) + expect(MONAD_TOKENS.map((t) => t.symbol)).toEqual(['MON', 'USDC', 'USDT0']) + expect(AVAX_USDC.id.toLowerCase().startsWith('0xb97ef9ef8734')).toBe(true) + expect(MONAD_USDC.id.toLowerCase().startsWith('0x754704bc059f')).toBe(true) + expect(AVAX.id).toBe(EVM_NATIVE_ID) + expect(MON.id).toBe(EVM_NATIVE_ID) + expect(AVAX.decimals).toBe(18) + expect(MONAD_USDC.decimals).toBe(6) + }) + + it('offers no venue without the secret that protects what would be issued', () => { + expect(buildEvmVenues('avalanche', {})).toEqual([]) + expect(buildEvmVenues('monad', { AUTH_SECRET: 'short' })).toEqual([]) + expect(buildEvmVenues('monad', { AUTH_SECRET: 'x'.repeat(32) }).map((v) => v.id)).toEqual([ + '1click-monad', + ]) + }) + + it('tells someone typing USDT on Monad to use USDT0, and finds USDT0 as written', () => { + const monadModule = chainModule('monad') + if (monadModule === undefined) throw new Error('no module') + expect(tokenBySymbol(monadModule.tokens, 'usdt0')).toBe(USDT0) + expect(tokenBySymbol(monadModule.tokens, 'USDT')).toBeUndefined() + const refusalOf = (request: ChainActionRequest): string => { + try { + assertKnownTokens(monadModule, request) + } catch (e) { + return (e as Error).message + } + throw new Error('nothing was refused') + } + expect(refusalOf(swap({ tokenIn: 'USDT', tokenOut: 'MON' }))).toMatch(/USDT0/) + expect(refusalOf(swap({ tokenIn: 'USDC', tokenOut: 'usdt' }))).toMatch(/USDT0/) + expect(() => assertKnownTokens(monadModule, swap({ tokenOut: 'MON' }))).not.toThrow() + }) + + it('prices a token from 1Click by its asset id', async () => { + await expect(chainModule('avalanche')?.priceUsd(AVAX)).resolves.toBe(20) + await expect(chainModule('monad')?.priceUsd(MON)).resolves.toBe(0.04) + await expect(chainModule('monad')?.priceUsd(USDT0)).resolves.toBe(1) + }) + + it('has no price, not an error, when 1Click cannot be read or does not list the token', async () => { + sim.tokens = sim.tokens.filter((t) => t['symbol'] !== 'MON') + await expect(chainModule('monad')?.priceUsd(MON)).resolves.toBeUndefined() + clearTokenCache() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('down') + }) + ) + await expect(chainModule('monad')?.priceUsd(MON)).resolves.toBeUndefined() + }) +}) + +describe('asset ids on these chains', () => { + it('matches a contract in any casing, and the native coin as the entry with no contract', async () => { + await expect(oneClickAssetId('avalanche', AVAX_USDC)).resolves.toBe('nep245:avax-usdc') + await expect(oneClickAssetId('avalanche', AVAX)).resolves.toBe('nep245:avax-native') + await expect(oneClickAssetId('monad', MON)).resolves.toBe('nep245:monad-native') + await expect(oneClickAssetId('monad', USDT0)).resolves.toBe('nep245:monad-usdt0') + }) + + it('finds no route when 1Click lists another contract under the same symbol', async () => { + sim.tokens = sim.tokens.filter((t) => t['assetId'] !== 'nep245:avax-usdc') + await expect(oneClickAssetId('avalanche', AVAX_USDC)).resolves.toBeUndefined() + await expect(venueAvalanche.quote?.(swap(), ACCOUNT)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.quotes).toEqual([]) + }) + + it('finds no route when the decimals disagree', async () => { + sim.tokens = sim.tokens.map((t) => + t['assetId'] === 'nep245:monad-usdc' ? { ...t, decimals: 18 } : t + ) + await expect(oneClickAssetId('monad', MONAD_USDC)).resolves.toBeUndefined() + }) + + it('does not take another chain’s entry for a contract', async () => { + sim.tokens = sim.tokens.map((t) => + t['assetId'] === 'nep245:monad-usdc' ? { ...t, blockchain: 'avax' } : t + ) + await expect(oneClickAssetId('monad', MONAD_USDC)).resolves.toBeUndefined() + }) +}) + +describe('the race on these chains', () => { + it('reaches the race from what a person types', () => { + expect(chainCapabilities('avalanche')).toBeDefined() + expect(chainCapabilities('monad')).toBeDefined() + expect(chainCapabilities('arc')).toBeUndefined() + expect(parseChainAction('swap 10 USDC to USDT0')).toMatchObject({ + kind: 'swap', + tokenIn: 'USDC', + tokenOut: 'USDT0', + }) + expect(capabilitySheet('avalanche')).toContain('Agents compete') + expect(capabilitySheet('monad')).toContain('Agents compete') + expect(capabilitySheet('monad')).not.toMatch(/Supply:|Stake:|Send:|limit order/) + expect(capabilitySheet('arc')).not.toContain('Agents compete') + }) + + it('quotes one dry route, with no deposit address, spending the native coin too', async () => { + const monadModule = chainModule('monad') + if (monadModule === undefined) throw new Error('no module') + const routes = await quoteChainRoutes( + monadModule, + swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' }), + ACCOUNT + ) + expect(routes.map((r) => r.id)).toEqual(['1click-monad-1']) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true]) + expect(JSON.stringify(routes)).not.toContain(DEPOSIT) + expect(routes[0]?.receiveAmount).toBe('4') + expect(singleRouteNote(routes.length, 'Monad')).toBe( + "One route available on Monad. The agents are judging whether it's worth taking." + ) + }) + + it('gives neither chain Stellar prices, Stellar assets or the other chain’s venues', () => { + for (const chain of ['avalanche', 'monad']) { + const market = buildMarketContext(chain, {}, 'mainnet') + expect(market.prices).toEqual({}) + expect(market.assets ?? []).toEqual([]) + const ids = market.venues.map((v) => v.id) + expect(ids).not.toContain(chain === 'avalanche' ? '1click-monad' : '1click-avalanche') + expect(ids).not.toContain('uniswap') + } + }) + + it('leaves Arc’s market as it was', () => { + const market = buildMarketContext('arc', {}, 'testnet') + expect(Object.keys(market.prices).length).toBeGreaterThan(0) + }) +}) + +describe('naming the chain you are on', () => { + const moduleFor = (slug: 'avalanche' | 'monad'): ChainModule => { + const found = chainModule(slug) + if (found === undefined) throw new Error('no module') + return found + } + const toNative = { avalanche: 'AVAX', monad: 'MON' } as const + + /** A chain with two venues, so a default and a named venue can be told apart. */ + function twoVenues(chain: 'solana' | 'sui' | 'near'): ChainModule { + const venue = (id: string, name: string): ChainVenue => ({ + id, + name, + kinds: ['swap'], + quote: async () => ({ receive: { amount: '1', symbol: 'B' } }), + prepare: async () => ({ venueId: id }) as PreparedAction, + assertSafe: () => undefined, + }) + return { + chain, + tokens: [ + { symbol: 'A', id: 'a', decimals: 6 }, + { symbol: 'B', id: 'b', decimals: 6 }, + ], + venues: [venue('alpha', 'Alpha Swap'), venue('beta', 'Beta Swap')], + defaultVenue: { swap: 'beta' }, + priceUsd: async () => 1, + submit: async () => ({ ok: false, error: 'unused here' }), + } + } + const ab = (venue: string): ChainActionRequest => ({ + kind: 'swap', + amount: '1', + tokenIn: 'A', + tokenOut: 'B', + venue, + }) + + it('reads as it was typed', () => { + expect(parseChainAction('swap 10 USDC to MON on monad')).toMatchObject({ venue: 'monad' }) + }) + + it.each<['avalanche' | 'monad', string]>([ + ['avalanche', 'avalanche'], + ['avalanche', 'Avalanche'], + ['avalanche', ' AVALANCHE '], + ['monad', 'monad'], + ['monad', 'Monad'], + ])('races on %s when told "on %s"', async (slug, venue) => { + const request = swap({ tokenOut: toNative[slug], venue }) + const routes = await quoteChainRoutes(moduleFor(slug), request, ACCOUNT) + expect(routes.map((r) => r.id)).toEqual([`1click-${slug}-1`]) + }) + + it.each<['avalanche' | 'monad', string]>([ + ['avalanche', 'avalanche'], + ['monad', 'Monad'], + ])('builds on %s when told "on %s"', async (slug, venue) => { + const request = swap({ tokenOut: toNative[slug], venue }) + const prepared = await prepareChainAction(moduleFor(slug), request, ACCOUNT, {}) + expect(prepared.venueId).toBe(`1click-${slug}`) + }) + + it.each(['solana', 'Solana', 'sui', 'near', 'NEAR', 'stellar', 'avalanche', 'arc'])( + 'does not quietly run on Monad when told "on %s"', + async (venue) => { + const request = swap({ tokenOut: 'MON', venue }) + await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([]) + await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow( + /can't swap here/ + ) + expect(sim.quotes.filter((q) => q['dry'] === false)).toEqual([]) + } + ) + + it.each(['1click', 'NEAR Intents', '1click-monad'])( + 'still takes a real venue name on Monad: %s', + async (venue) => { + const request = swap({ tokenOut: 'MON', venue }) + const routes = await quoteChainRoutes(moduleFor('monad'), request, ACCOUNT) + expect(routes.map((r) => r.id)).toEqual(['1click-monad-1']) + const prepared = await prepareChainAction(moduleFor('monad'), request, ACCOUNT, {}) + expect(prepared.venueId).toBe('1click-monad') + } + ) + + it('refuses a venue that isn’t here', async () => { + const request = swap({ tokenOut: 'MON', venue: 'jupiter' }) + await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([]) + await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow( + /"jupiter" can't swap here/ + ) + }) + + it.each<['solana' | 'sui' | 'near', string]>([ + ['solana', 'solana'], + ['sui', 'Sui'], + ['near', 'NEAR'], + ])('counts as no venue named on %s too: "%s"', async (chain, venue) => { + const api = twoVenues(chain) + const routes = await quoteChainRoutes(api, ab(venue), ACCOUNT) + expect(routes.map((r) => r.id)).toEqual(['alpha-1', 'beta-1']) + await expect(prepareChainAction(api, ab(venue), ACCOUNT, {})).resolves.toMatchObject({ + venueId: 'beta', + }) + }) + + it('still filters by a real venue, and refuses another chain, on those chains', async () => { + const api = twoVenues('solana') + const routes = await quoteChainRoutes(api, ab('alpha'), ACCOUNT) + expect(routes.map((r) => r.id)).toEqual(['alpha-1']) + await expect(prepareChainAction(api, ab('alpha'), ACCOUNT, {})).resolves.toMatchObject({ + venueId: 'alpha', + }) + await expect(quoteChainRoutes(api, ab('monad'), ACCOUNT)).resolves.toEqual([]) + await expect(prepareChainAction(api, ab('monad'), ACCOUNT, {})).rejects.toThrow( + /"monad" can't swap here/ + ) + }) +}) + +describe('the Apps page rows', () => { + const on = (slug: 'avalanche' | 'monad' | 'arc' | 'stellar'): string[] => + venues.filter((v) => venueOnChain(v, CHAIN_DESCRIPTORS[slug])).map((v) => v.id) + + it('shows each chain its own row and no venue that does not name it', () => { + expect(on('avalanche')).toEqual(['1click-avalanche']) + expect(on('monad')).toEqual(['1click-monad']) + }) + + it('changes nothing for another family or for Arc', () => { + expect(on('stellar')).toEqual(venues.filter((v) => v.family === 'stellar').map((v) => v.id)) + const arc = on('arc') + expect(arc).toContain('uniswap') + expect(arc).not.toContain('1click-avalanche') + expect(arc).not.toContain('1click-monad') + const marketOnArc = buildMarketContext('arc', {}, 'testnet').venues.map((v) => v.id) + expect(marketOnArc).toEqual(arc) + }) + + it('claims no execution, because the rows are client-safe and can’t read the secret', () => { + for (const id of ['1click-avalanche', '1click-monad']) { + const row = venues.find((v) => v.id === id) + expect(row?.family).toBe('evm') + expect(row?.integration).toBeUndefined() + } + }) +}) + +describe('assertSafe: what may be signed', () => { + const client = (): never => { + throw new Error('assertSafe must not read the chain') + } + const avalancheDeposit = createEvmDeposit('avalanche', client) + const monadDeposit = createEvmDeposit('monad', client) + + const nativeOrder = { + account: ACCOUNT, + depositAddress: DEPOSIT, + memo: undefined, + amount: '1.5', + token: AVAX, + } + const usdcOrder = { ...nativeOrder, amount: '25', token: AVAX_USDC } + + const transfer = (to: string, units: bigint): string => + encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [to as Address, units], + }) + + const nativePayload = (change: Json = {}): SigningPayload => + ({ + family: 'evm', + chainId: 43114, + to: DEPOSIT, + data: '0x', + value: ONE_AND_A_HALF_AVAX, + ...change, + }) as SigningPayload + + const tokenPayload = (change: Json = {}): SigningPayload => + ({ + family: 'evm', + chainId: 43114, + to: AVAX_USDC.id, + data: transfer(DEPOSIT, USD_UNITS), + value: '0', + ...change, + }) as SigningPayload + + it('admits a native transfer only with the exact value and empty data', () => { + expect(() => avalancheDeposit.assertSafe(nativePayload(), nativeOrder)).not.toThrow() + }) + + it.each<[string, Json]>([ + ['a value one unit more', { value: '1500000000000000001' }], + ['a value one unit less', { value: '1499999999999999999' }], + ['no value', { value: '0' }], + ['a value in hex', { value: '0x14d1120d7b160000' }], + ['a padded value', { value: `0${ONE_AND_A_HALF_AVAX}` }], + ['data on the transfer', { data: '0xdeadbeef' }], + ['a transfer call as data', { data: transfer(DEPOSIT, USD_UNITS) }], + ['no data field at all', { data: '' }], + ['another recipient', { to: OTHER }], + ['the token contract as recipient', { to: AVAX_USDC.id }], + ['Monad’s chain id', { chainId: 143 }], + ])('refuses a native transfer with %s', (_name, change) => { + expect(() => avalancheDeposit.assertSafe(nativePayload(change), nativeOrder)).toThrow( + ChainActionRefused + ) + }) + + it('admits an ERC-20 transfer only with no value and exactly one transfer call', () => { + expect(() => avalancheDeposit.assertSafe(tokenPayload(), usdcOrder)).not.toThrow() + expect(() => + avalancheDeposit.assertSafe(tokenPayload({ to: AVAX_USDC.id.toLowerCase() }), usdcOrder) + ).not.toThrow() + }) + + it.each<[string, Json]>([ + ['value attached', { value: '1' }], + ['the deposit as the call target', { to: DEPOSIT }], + ['USDT in place of USDC', { to: AVAX_USDT.id }], + ['another recipient', { data: transfer(OTHER, USD_UNITS) }], + ['a different amount', { data: transfer(DEPOSIT, USD_UNITS + BigInt(1)) }], + ['bytes after the transfer', { data: `${transfer(DEPOSIT, USD_UNITS)}00` }], + [ + 'dirty padding before the address', + { data: `0xa9059cbb${'f'.repeat(24)}${'2'.repeat(40)}${word('17d7840')}` }, + ], + ['an approve', { data: `0x095ea7b3${addressWord(DEPOSIT)}${word('17d7840')}` }], + ['no calldata', { data: '0x' }], + ['Monad’s chain id', { chainId: 143 }], + ])('refuses an ERC-20 transfer with %s', (_name, change) => { + expect(() => avalancheDeposit.assertSafe(tokenPayload(change), usdcOrder)).toThrow( + ChainActionRefused + ) + }) + + it('requires 143 on Monad and refuses 43114 there', () => { + const order = { ...nativeOrder, amount: '100', token: MON } + const units = '100000000000000000000' + const payload = (chainId: number): SigningPayload => ({ + family: 'evm', + chainId, + to: DEPOSIT, + data: '0x', + value: units, + }) + expect(() => monadDeposit.assertSafe(payload(143), order)).not.toThrow() + expect(() => monadDeposit.assertSafe(payload(43114), order)).toThrow(/Monad/) + }) + + it('never assumes USDC: a USDT order is a USDT transfer, and no token at all is refused', () => { + const usdtOrder = { ...usdcOrder, token: AVAX_USDT } + expect(() => + avalancheDeposit.assertSafe(tokenPayload({ to: AVAX_USDT.id }), usdtOrder) + ).not.toThrow() + expect(() => avalancheDeposit.assertSafe(tokenPayload(), usdtOrder)).toThrow(ChainActionRefused) + const { token: _absent, ...noToken } = usdcOrder + expect(() => avalancheDeposit.assertSafe(tokenPayload(), noToken)).toThrow(ChainActionRefused) + }) + + it('refuses a token from another chain’s list', () => { + expect(() => + avalancheDeposit.assertSafe(tokenPayload({ to: MONAD_USDC.id }), { + ...usdcOrder, + token: MONAD_USDC, + }) + ).toThrow(ChainActionRefused) + }) + + it('refuses another family, and a memo', () => { + expect(() => + avalancheDeposit.assertSafe({ family: 'solana', transaction: 'AAAA' }, nativeOrder) + ).toThrow(ChainActionRefused) + expect(() => + avalancheDeposit.assertSafe(nativePayload(), { ...nativeOrder, memo: '42' }) + ).toThrow(/memo/i) + }) + + it('takes no token contract and no zero address as a deposit address', () => { + expect(avalancheDeposit.isDepositAddress(DEPOSIT)).toBe(true) + expect(avalancheDeposit.isDepositAddress(AVAX_USDC.id.toLowerCase())).toBe(false) + expect(avalancheDeposit.isDepositAddress(AVAX_USDT.id)).toBe(false) + expect(monadDeposit.isDepositAddress(USDT0.id)).toBe(false) + expect(monadDeposit.isDepositAddress(ZERO)).toBe(false) + expect(monadDeposit.isAccount(ZERO)).toBe(false) + expect(monadDeposit.isAccount('not an address')).toBe(false) + }) +}) + +describe('prepare', () => { + it('builds a native transfer of exactly the quoted amount, after a dry and a real quote', async () => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true, false]) + expect(prepared.signing).toEqual({ + family: 'evm', + chainId: 43114, + to: DEPOSIT, + data: '0x', + value: ONE_AND_A_HALF_AVAX, + }) + expect(prepared.pay).toEqual({ amount: '1.5', symbol: 'AVAX' }) + expect(prepared.context?.['amount']).toBe(ONE_AND_A_HALF_AVAX) + expect(prepared.context?.['token']).toBe(EVM_NATIVE_ID) + expect(prepared.summary).toContain('Avalanche') + expect(sim.quotes[0]?.['originAsset']).toBe('nep245:avax-native') + }) + + it('builds a transfer of the token asked for, not USDC', async () => { + const prepared = await venueAvalanche.prepare(swap({ tokenIn: 'USDT' }), ACCOUNT) + const expected: SigningPayload = { + family: 'evm', + chainId: 43114, + to: AVAX_USDT.id, + data: encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS], + }), + value: '0', + } + expect(prepared.signing).toEqual(expected) + expect(prepared.context?.['token']).toBe(AVAX_USDT.id) + expect(() => + venueAvalanche.assertSafe(prepared.signing, ACCOUNT, prepared.context) + ).not.toThrow() + }) + + it('builds on Monad for chain 143, through the whole prepare path', async () => { + const monadModule = chainModule('monad') + if (monadModule === undefined) throw new Error('no module') + const prepared = await prepareChainAction( + monadModule, + swap({ tokenIn: 'MON', tokenOut: 'USDT0', amount: '100' }), + ACCOUNT, + {} + ) + expect(prepared.venueId).toBe('1click-monad') + expect(prepared.signing).toMatchObject({ + family: 'evm', + chainId: 143, + to: DEPOSIT, + value: '100000000000000000000', + }) + expect(prepared.summary).toContain('Monad') + }) + + it('refuses a deposit address that is the token contract, or not an address', async () => { + for (const bad of [AVAX_USDC.id, 'not-an-address', ZERO]) { + sim.depositAddress = bad + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).rejects.toThrow(/deposit address/i) + } + }) + + it('refuses an account that is not an EVM address, in words that read properly', async () => { + await expect(venueAvalanche.prepare(swap(), 'alice.near')).rejects.toThrow( + 'That is not an Avalanche wallet address.' + ) + await expect(venueMonad.prepare(swap({ tokenOut: 'MON' }), 'alice.near')).rejects.toThrow( + 'That is not a Monad wallet address.' + ) + }) + + describe('reading what the wallet holds', () => { + const reserve = (chain: 'avalanche' | 'monad'): bigint => BigInt(EVM_CHAINS[chain].gasReserve) + + function expectNoAddressAsked(): void { + expect(sim.quotes.filter((q) => q['dry'] === false)).toEqual([]) + } + + it('reads the token from its own contract, for this account', async () => { + const seen: string[][] = [] + rpc.tokenBalance = async (tokenId, account) => { + seen.push([tokenId, account]) + return USD_UNITS + } + await venueAvalanche.prepare(swap({ tokenIn: 'USDT' }), ACCOUNT) + expect(seen).toEqual([[AVAX_USDT.id, ACCOUNT]]) + }) + + it('refuses less of the token than the swap needs, before any deposit address', async () => { + rpc.tokenBalance = async () => USD_UNITS - BigInt(1) + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).rejects.toThrow( + /less USDC.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + }) + + it('takes exactly enough of the token', async () => { + rpc.tokenBalance = async () => USD_UNITS + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).resolves.toBeDefined() + }) + + it('refuses a token swap with too little of the coin that pays for gas', async () => { + rpc.nativeBalance = async () => reserve('monad') - BigInt(1) + await expect(venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT)).rejects.toThrow( + /MON.*network fee.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + }) + + it('keeps gas back when the native coin is what is being spent', async () => { + const amount = BigInt(ONE_AND_A_HALF_AVAX) + rpc.nativeBalance = async () => amount + await expect(venueAvalanche.prepare(avaxSwap(), ACCOUNT)).rejects.toThrow( + /network fee.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + rpc.nativeBalance = async () => amount + reserve('avalanche') - BigInt(1) + await expect(venueAvalanche.prepare(avaxSwap(), ACCOUNT)).rejects.toBeInstanceOf( + ChainActionRefused + ) + expectNoAddressAsked() + rpc.nativeBalance = async () => amount + reserve('avalanche') + await expect(venueAvalanche.prepare(avaxSwap(), ACCOUNT)).resolves.toBeDefined() + }) + + it.each<[string, () => void]>([ + [ + 'the token', + () => { + rpc.tokenBalance = async () => { + throw new Error('rpc down') + } + }, + ], + [ + 'the gas coin', + () => { + rpc.nativeBalance = async () => { + throw new Error('rpc down') + } + }, + ], + ])('fails closed when %s cannot be read', async (_name, arrange) => { + arrange() + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).rejects.toThrow( + /Could not read.*Avalanche.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + }) + + describe('Monad’s 10 MON reserve', () => { + const TEN_MON = BigInt(10) * BigInt(10) ** BigInt(18) + const HUNDRED_MON = BigInt(100) * BigInt(10) ** BigInt(18) + const monSwap = swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' }) + const hasNote = (prepared: PreparedAction): boolean => + prepared.notes.some((note) => /10 MON reserve/.test(note)) + + it('says so on the card, and still builds, when a spend leaves under 10 MON', async () => { + rpc.nativeBalance = async () => HUNDRED_MON + TEN_MON - BigInt(1) + const prepared = await venueMonad.prepare(monSwap, ACCOUNT) + expect(hasNote(prepared)).toBe(true) + expect(prepared.notes.join(' ')).toMatch(/gas/) + expect(prepared.signing).toMatchObject({ family: 'evm', chainId: 143 }) + }) + + it('says nothing when a spend leaves exactly 10 MON, or more', async () => { + rpc.nativeBalance = async () => HUNDRED_MON + TEN_MON + expect(hasNote(await venueMonad.prepare(monSwap, ACCOUNT))).toBe(false) + rpc.nativeBalance = async () => HUNDRED_MON * BigInt(2) + expect(hasNote(await venueMonad.prepare(monSwap, ACCOUNT))).toBe(false) + }) + + it('says nothing for a token, which leaves the coin alone', async () => { + rpc.nativeBalance = async () => BigInt(EVM_CHAINS.monad.gasReserve) + expect(hasNote(await venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT))).toBe(false) + }) + + it('says nothing on Avalanche, which has no such rule', async () => { + rpc.nativeBalance = async () => + BigInt(ONE_AND_A_HALF_AVAX) + BigInt(EVM_CHAINS.avalanche.gasReserve) + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(prepared.notes.join(' ')).not.toMatch(/reserve/i) + }) + }) + + it('does not read balances for a race quote', async () => { + rpc.tokenBalance = async () => { + throw new Error('a quote must not read this') + } + await expect(venueAvalanche.quote?.(swap(), ACCOUNT)).resolves.toBeDefined() + }) + }) + + describe('the signed context', () => { + it.each<[string, Record]>([ + ['another deposit address', { depositAddress: OTHER }], + ['another amount', { amount: '1' }], + ['another token', { token: AVAX_USDC.id }], + ['a later deadline', { deadline: '2099-01-01T00:00:00.000Z' }], + ['another proof', { proof: 'forged' }], + ])('refuses %s', async (_name, change) => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(() => + venueAvalanche.assertSafe(prepared.signing, ACCOUNT, { ...prepared.context, ...change }) + ).toThrow(ChainActionRefused) + }) + + it('does not pass for another account or on the other chain', async () => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(() => venueAvalanche.assertSafe(prepared.signing, OTHER, prepared.context)).toThrow( + ChainActionRefused + ) + expect(() => + venueMonad.assertSafe({ ...prepared.signing, chainId: 143 } as SigningPayload, ACCOUNT, { + ...(prepared.context as Record), + }) + ).toThrow(ChainActionRefused) + }) + + it('does not let the browser spend USDC under a proof that named USDT', async () => { + const prepared = await venueAvalanche.prepare(swap({ tokenIn: 'USDT' }), ACCOUNT) + expect(() => + venueAvalanche.assertSafe( + { ...prepared.signing, to: AVAX_USDC.id } as SigningPayload, + ACCOUNT, + prepared.context + ) + ).toThrow(ChainActionRefused) + }) + }) +}) + +describe('submit: what the chain says the wallet sent', () => { + describe('a native transfer', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + rpc.transaction = async () => ({ value: BigInt(ONE_AND_A_HALF_AVAX), input: '0x' }) + }) + + it('accepts the transfer, tells 1Click, and is done when 1Click says so', async () => { + rpc.waitForReceipt = async () => receipt() + await expect(submitTo(prepared)).resolves.toEqual({ + ok: true, + hash: HASH, + explorerUrl: `https://snowtrace.io/tx/${HASH}`, + }) + expect(sim.notified).toEqual([{ txHash: HASH, depositAddress: DEPOSIT }]) + }) + + it.each<[string, Json]>([ + ['from another account', { from: OTHER }], + ['to another address', { to: OTHER }], + ['to no address', { to: null }], + ])('does not take a receipt %s as this order’s transfer', async (_name, change) => { + rpc.waitForReceipt = async () => receipt(change) + const result = await submitTo(prepared) + isPending(result) + expect(result).toMatchObject({ error: expect.stringMatching(/not this swap/) }) + expect(sim.notified).toEqual([]) + }) + + it('reads the value, since a receipt has none, and refuses any other value', async () => { + rpc.waitForReceipt = async () => receipt() + rpc.transaction = async () => ({ + value: BigInt(ONE_AND_A_HALF_AVAX) - BigInt(1), + input: '0x', + }) + const result = await submitTo(prepared) + isPending(result) + expect(sim.notified).toEqual([]) + }) + + it('stays pending when a landed transfer’s value cannot be read', async () => { + rpc.waitForReceipt = async () => receipt() + rpc.transaction = async () => { + throw new Error('rpc down') + } + isPending(await submitTo(prepared)) + expect(sim.notified).toEqual([]) + }) + + it('calls a revert clean only when it is this account’s, to this deposit address', async () => { + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + const clean = await submitTo(prepared) + withoutPending(clean) + expect(clean).toMatchObject({ error: expect.stringMatching(/AVAX did not move/) }) + expect(sim.notified).toEqual([]) + + for (const change of [{ from: OTHER }, { to: OTHER }, { to: null }]) { + rpc.waitForReceipt = async () => receipt({ status: 'reverted', ...change }) + isPending(await submitTo(prepared)) + } + }) + + it('does not call a revert clean when the transaction carried data, or can’t be read', async () => { + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt(ONE_AND_A_HALF_AVAX), input: '0xdeadbeef' }) + isPending(await submitTo(prepared)) + rpc.transaction = async () => ({ value: BigInt(1), input: '0x' }) + isPending(await submitTo(prepared)) + rpc.transaction = async () => { + throw new Error('rpc down') + } + isPending(await submitTo(prepared)) + }) + + it('names no reserve on Avalanche, which has none', async () => { + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + expect(await submitTo(prepared)).not.toMatchObject({ + error: expect.stringMatching(/reserve/i), + }) + }) + }) + + describe('a native transfer on Monad that reverts', () => { + it('names the 10 MON reserve as the likely cause', async () => { + const prepared = await venueMonad.prepare( + swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' }), + ACCOUNT + ) + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt('100000000000000000000'), input: '0x' }) + const clean = await submitTo(prepared, venueMonad) + withoutPending(clean) + expect(clean).toMatchObject({ error: expect.stringMatching(/10 MON reserve/) }) + expect(clean).toMatchObject({ error: expect.stringMatching(/MON did not move/) }) + }) + + it('does not blame the reserve for a token transfer that reverts on Monad', async () => { + const prepared = await venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT) + rpc.waitForReceipt = async () => receipt({ status: 'reverted', to: MONAD_USDC.id }) + rpc.transaction = async () => ({ + value: BigInt(0), + input: encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS], + }), + }) + const clean = await submitTo(prepared, venueMonad) + withoutPending(clean) + expect(clean).not.toMatchObject({ error: expect.stringMatching(/reserve/i) }) + }) + }) + + describe('an ERC-20 transfer', () => { + let prepared: PreparedAction + const paying = (logs: unknown[], change: Json = {}): Json => + receipt({ to: AVAX_USDC.id, logs, ...change }) + const thisTransfer = encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS], + }) + + beforeEach(async () => { + prepared = await venueAvalanche.prepare(swap(), ACCOUNT) + rpc.transaction = async () => ({ value: BigInt(0), input: thisTransfer }) + }) + + it.each<[string, string]>([ + [ + 'a transfer to someone else', + encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [OTHER, USD_UNITS], + }), + ], + [ + 'a transfer of another amount', + encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS + BigInt(1)], + }), + ], + ['an approve', `0x095ea7b3${addressWord(DEPOSIT)}${word('17d7840')}`], + ['this transfer with bytes after it', `${thisTransfer}00`], + ['nothing', '0x'], + ])( + 'does not read an earlier failed call on this token, %s, as this transfer failing', + async (_name, input) => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt(0), input }) + const result = await submitTo(prepared) + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/did not move/) }) + } + ) + + it('does not call a revert this transfer when it also carried value', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt(1), input: thisTransfer }) + isPending(await submitTo(prepared)) + }) + + it('stays pending on a revert when the transaction can’t be read', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + rpc.transaction = async () => { + throw new Error('rpc down') + } + const result = await submitTo(prepared) + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/did not move/) }) + }) + + it('accepts a Transfer log of exactly this amount, from the account to the deposit', async () => { + rpc.waitForReceipt = async () => paying([transferLog(AVAX_USDC.id, USD_UNITS)]) + await expect(submitTo(prepared)).resolves.toMatchObject({ ok: true, hash: HASH }) + expect(sim.notified).toHaveLength(1) + }) + + it.each<[string, () => Json]>([ + [ + 'from another account', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { from: OTHER }), + ], + ['to another contract', () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { to: OTHER })], + ['with no logs', () => paying([])], + ['with a log from another token', () => paying([transferLog(AVAX_USDT.id, USD_UNITS)])], + [ + 'with a log from another sender', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS, { from: OTHER })]), + ], + [ + 'with a log to another recipient', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS, { to: OTHER })]), + ], + [ + 'with a log of another amount', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS + BigInt(1))]), + ], + ])('does not take a receipt %s as this order’s transfer', async (_name, build) => { + rpc.waitForReceipt = async () => build() + isPending(await submitTo(prepared)) + expect(sim.notified).toEqual([]) + }) + + it('calls a revert clean only when it is this account’s, on this token', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + const clean = await submitTo(prepared) + withoutPending(clean) + expect(clean).toMatchObject({ error: expect.stringMatching(/USDC did not move/) }) + + rpc.waitForReceipt = async () => paying([], { status: 'reverted', from: OTHER }) + isPending(await submitTo(prepared)) + rpc.waitForReceipt = async () => paying([], { status: 'reverted', to: DEPOSIT }) + isPending(await submitTo(prepared)) + }) + + it('keeps the latch set on a clean revert, because the wallet sent for itself', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + const clean = await submitTo(prepared) + expect(afterSubmitting(clean, 'evm')).toMatchObject({ latched: true }) + }) + }) + + describe('what is never called clean', () => { + let prepared: PreparedAction + let hasNoClaimOfNothing: (result: SubmitResult) => void + + beforeEach(async () => { + prepared = await venueAvalanche.prepare(swap(), ACCOUNT) + hasNoClaimOfNothing = (result) => { + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/nothing was/i) }) + } + }) + + it('a receipt that does not come, which is what a hash from another chain gives', async () => { + rpc.waitForReceipt = async () => { + throw new Error('timed out') + } + const result = await submitTo(prepared) + hasNoClaimOfNothing(result) + expect(result).toMatchObject({ error: expect.stringContaining(HASH) }) + expect(sim.notified).toEqual([]) + }) + + it.each(['0x1234', `0x${'g'.repeat(64)}`, HASH.slice(2), `${HASH}00`, ''])( + 'a wallet answer that is not a hash: %s', + async (hash) => { + const result = await submitTo(prepared, venueAvalanche, { hash }) + hasNoClaimOfNothing(result) + expect(rpc.reads).toEqual([]) + } + ) + + it('a quote that expired once the wallet had already sent: it is still followed', async () => { + rpc.waitForReceipt = async () => + receipt({ to: AVAX_USDC.id, logs: [transferLog(AVAX_USDC.id, USD_UNITS)] }) + const issuedAt = Date.now() + vi.useFakeTimers({ now: issuedAt + 10 * 60_000, toFake: ['Date'] }) + await expect(submitTo(prepared)).resolves.toMatchObject({ ok: true }) + expect(sim.notified).toHaveLength(1) + }) + + it.each<[string, Record]>([ + ['a changed deposit address', { depositAddress: OTHER }], + ['a changed amount', { amount: '1' }], + ['a forged proof', { proof: 'forged' }], + ])('details that were not issued, %s: the wallet may have sent anyway', async (_n, change) => { + const result = await submitTo(prepared, venueAvalanche, { + context: { ...(prepared.context as Record), ...change }, + }) + hasNoClaimOfNothing(result) + expect(result).toMatchObject({ error: expect.stringContaining(HASH) }) + expect(rpc.reads).toEqual([]) + expect(sim.notified).toEqual([]) + }) + + it('a proof made for the other chain', async () => { + const result = await submitTo(prepared, venueMonad) + hasNoClaimOfNothing(result) + expect(rpc.reads).toEqual([]) + }) + + it('a proof made for another account', async () => { + const result = await submitTo(prepared, venueAvalanche, { account: OTHER }) + hasNoClaimOfNothing(result) + }) + + it('another family handed to this chain', async () => { + const result = await (venueAvalanche.submit as NonNullable)({ + account: ACCOUNT, + venueId: venueAvalanche.id, + signed: { family: 'solana', signedTransaction: 'AAAA' }, + context: prepared.context, + }) + hasNoClaimOfNothing(result) + }) + + it('a read that throws anything at all', async () => { + rpc.waitForReceipt = async () => { + throw new ChainActionRefused('a refusal is no proof once the wallet has sent') + } + hasNoClaimOfNothing(await submitTo(prepared)) + }) + + it('a refusal thrown while reading what came back', async () => { + rpc.waitForReceipt = async () => ({ + get from(): string { + throw new ChainActionRefused('a refusal is no proof once the wallet has sent') + }, + }) + hasNoClaimOfNothing(await submitTo(prepared)) + }) + }) + + describe('after the transfer lands', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT) + rpc.waitForReceipt = async () => + receipt({ to: MONAD_USDC.id, logs: [transferLog(MONAD_USDC.id, USD_UNITS)] }) + }) + + it('links Monad’s explorer', async () => { + await expect(submitTo(prepared, venueMonad)).resolves.toEqual({ + ok: true, + hash: HASH, + explorerUrl: `https://monadvision.com/tx/${HASH}`, + }) + }) + + it.each(['REFUNDED', 'FAILED', 'INCOMPLETE_DEPOSIT'])( + 'calls 1Click’s %s pending, and says where funds should return', + async (status) => { + sim.statuses = [status] + const result = await submitTo(prepared, venueMonad) + isPending(result) + expect(result).toMatchObject({ error: expect.stringContaining(ACCOUNT) }) + } + ) + + it('answers a running swap as pending inside the route’s 60 seconds', async () => { + sim.statuses = ['PROCESSING'] + vi.useFakeTimers() + const started = Date.now() + let settledAt: number | undefined + const running = submitTo(prepared, venueMonad).then((r) => { + settledAt = Date.now() + return r + }) + await vi.advanceTimersByTimeAsync(60_000) + isPending(await running) + expect((settledAt as number) - started).toBeLessThan(60_000) + }) + + it('does not mistake a recipient in another casing for another swap', async () => { + const mixed = '0xAbCdEf0123456789aBcDeF0123456789abcdef01' + const mine = await venueMonad.prepare(swap({ tokenOut: 'MON' }), mixed) + rpc.waitForReceipt = async () => + receipt({ + from: mixed, + to: MONAD_USDC.id, + logs: [transferLog(MONAD_USDC.id, USD_UNITS, { from: mixed })], + }) + sim.statusRecipient = mixed.toLowerCase() + await expect(submitTo(mine, venueMonad, { account: mixed })).resolves.toMatchObject({ + ok: true, + }) + }) + + it('holds when 1Click’s record names someone else', async () => { + sim.statusRecipient = OTHER + const result = await submitTo(prepared, venueMonad) + isPending(result) + expect(result).toMatchObject({ error: expect.stringMatching(/different swap/) }) + }) + }) + + // At the deposit itself, because the venue would turn a throw into pending and hide it here. + describe('an answer that makes no sense still gets an answer', () => { + const usdcOrder = { + account: ACCOUNT, + depositAddress: DEPOSIT, + memo: undefined, + amount: '25', + token: AVAX_USDC, + } + const avaxOrder = { ...usdcOrder, amount: '1.5', token: AVAX } + + const deposit = (receiptTo: string, status: string, transaction: () => Promise) => + createEvmDeposit('avalanche', () => ({ + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => receipt({ status, to: receiptTo }) as unknown as EvmReceipt, + transaction: transaction as EvmClient['transaction'], + })) + + function pendingAndClaimsNothing(result: SubmitResult): void { + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/did not move/) }) + } + + it.each<[string, () => Promise]>([ + ['no input', async () => ({ value: BigInt(0) })], + ['null', async () => null], + ['an input that is not text', async () => ({ value: BigInt(0), input: 7 })], + ])('a token revert whose read resolves to %s is pending', async (_name, read) => { + const chain = deposit(AVAX_USDC.id, 'reverted', read) + pendingAndClaimsNothing(await chain.submit({ family: 'evm', hash: HASH }, usdcOrder)) + }) + + it.each<[string, () => Promise]>([ + ['no input', async () => ({ value: BigInt(ONE_AND_A_HALF_AVAX) })], + ['null', async () => null], + ])('a native revert whose read resolves to %s is pending', async (_name, read) => { + const chain = deposit(DEPOSIT, 'reverted', read) + pendingAndClaimsNothing(await chain.submit({ family: 'evm', hash: HASH }, avaxOrder)) + }) + + it.each(['not a number', '', '0', '1.1234567'])( + 'a malformed amount, %j, is pending on a revert and on a landed transfer', + async (amount) => { + for (const status of ['reverted', 'success']) { + const read = async () => ({ value: BigInt(0), input: '0x' }) + const chain = deposit(AVAX_USDC.id, status, read) + pendingAndClaimsNothing( + await chain.submit({ family: 'evm', hash: HASH }, { ...usdcOrder, amount }) + ) + } + } + ) + + it('a malformed native amount is pending on a revert too', async () => { + const read = async () => ({ value: BigInt(0), input: '0x' }) + const chain = deposit(DEPOSIT, 'reverted', read) + pendingAndClaimsNothing( + await chain.submit({ family: 'evm', hash: HASH }, { ...avaxOrder, amount: 'not a number' }) + ) + }) + }) + + describe('through the real client', () => { + function transport(answers: Record) { + return custom({ + request: async ({ method }: { method: string }) => answers[method] ?? null, + }) + } + + const order = { + account: ACCOUNT, + depositAddress: DEPOSIT, + memo: undefined, + amount: '100', + token: MON, + } + + const rpcReceipt = { + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x5208', + effectiveGasPrice: '0x1', + from: ACCOUNT, + gasUsed: '0x5208', + logs: [], + logsBloom: `0x${'0'.repeat(512)}`, + status: '0x1', + to: DEPOSIT, + transactionHash: HASH, + transactionIndex: '0x0', + type: '0x2', + } + + const rpcTransaction = (hexValue: string): Json => ({ + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: '0x1', + from: ACCOUNT, + gas: '0x5208', + gasPrice: '0x1', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + hash: HASH, + input: '0x', + nonce: '0x0', + to: DEPOSIT, + transactionIndex: '0x0', + value: hexValue, + type: '0x2', + accessList: [], + chainId: '0x8f', + v: '0x1', + r: `0x${'1'.repeat(64)}`, + s: `0x${'2'.repeat(64)}`, + yParity: '0x1', + }) + + async function deposit(answers: Record) { + const { evmClient } = await import('../chains/evm/client') + return createEvmDeposit('monad', () => evmClient('monad', transport(answers))) + } + + const ONE_HUNDRED_MON_HEX = `0x${(BigInt(100) * BigInt(10) ** BigInt(18)).toString(16)}` + + it('confirms a real receipt and a real transaction shape', async () => { + const chain = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: rpcReceipt, + eth_getTransactionByHash: rpcTransaction(ONE_HUNDRED_MON_HEX), + }) + await expect(chain.submit({ family: 'evm', hash: HASH }, order)).resolves.toMatchObject({ + ok: true, + }) + }) + + it('reads the input of a real reverted transaction before calling it clean', async () => { + const reverted = { ...rpcReceipt, status: '0x0' } + const clean = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: reverted, + eth_getTransactionByHash: rpcTransaction(ONE_HUNDRED_MON_HEX), + }) + withoutPending(await clean.submit({ family: 'evm', hash: HASH }, order)) + + const other = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: reverted, + eth_getTransactionByHash: { ...rpcTransaction(ONE_HUNDRED_MON_HEX), input: '0xdeadbeef' }, + }) + isPending(await other.submit({ family: 'evm', hash: HASH }, order)) + }) + + it('refuses a real transaction of another value', async () => { + const chain = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: rpcReceipt, + eth_getTransactionByHash: rpcTransaction('0x1'), + }) + isPending(await chain.submit({ family: 'evm', hash: HASH }, order)) + }) + + it('gives up well inside the route’s limit on a hash this chain has not seen', async () => { + vi.useFakeTimers() + const chain = await deposit({ eth_blockNumber: '0x2' }) + let settled: SubmitResult | undefined + const running = chain.submit({ family: 'evm', hash: HASH }, order).then((r) => { + settled = r + return r + }) + await vi.advanceTimersByTimeAsync(15_000) + expect(settled).toBeUndefined() + await vi.advanceTimersByTimeAsync(10_000) + isPending(await running) + }) + + it('reads the token’s balance from its contract and the coin as the native balance', async () => { + const seen: { method: string; params: unknown[] }[] = [] + const { evmClient } = await import('../chains/evm/client') + const client = evmClient( + 'avalanche', + custom({ + request: async ({ method, params }: { method: string; params: unknown[] }) => { + seen.push({ method, params }) + return method === 'eth_call' ? `0x${word('bebc20')}` : '0x5' + }, + }) + ) + await expect(client.tokenBalance(AVAX_USDT.id as Address, ACCOUNT as Address)).resolves.toBe( + BigInt(12_500_000) + ) + await expect(client.nativeBalance(ACCOUNT as Address)).resolves.toBe(BigInt(5)) + const call = seen.find((s) => s.method === 'eth_call')?.params[0] as { to: string } + expect(call.to.toLowerCase()).toBe(AVAX_USDT.id.toLowerCase()) + }) + }) + + describe('which network the client talks to', () => { + function rpcCalls(): string[] { + const urls: string[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL) => { + urls.push(String(input)) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x5' })) + }) + ) + return urls + } + + // The mock hands back the real client only for a transport, so these go through the original. + async function realClient(chain: 'avalanche' | 'monad') { + const mocked = (await import('../chains/evm/client')) as unknown as { + realEvmClient: typeof import('../chains/evm/client').evmClient + } + return mocked.realEvmClient(chain) + } + + it('uses MONAD_RPC_URL when it is set', async () => { + const urls = rpcCalls() + vi.stubEnv('MONAD_RPC_URL', ' https://monad.example.test/rpc ') + const client = await realClient('monad') + await client.nativeBalance(ACCOUNT as Address) + expect(urls).toEqual(['https://monad.example.test/rpc']) + }) + + it('falls back to viem’s default for Monad', async () => { + const urls = rpcCalls() + vi.stubEnv('MONAD_RPC_URL', '') + const client = await realClient('monad') + await client.nativeBalance(ACCOUNT as Address) + expect(urls.map((url) => new URL(url).origin)).toEqual([ + new URL(monad.rpcUrls.default.http[0]).origin, + ]) + }) + + it('uses AVALANCHE_RPC_URL on Avalanche', async () => { + const urls = rpcCalls() + vi.stubEnv('AVALANCHE_RPC_URL', 'https://avax.example.test/rpc') + const client = await realClient('avalanche') + await client.nativeBalance(ACCOUNT as Address) + expect(urls).toEqual(['https://avax.example.test/rpc']) + }) + }) +}) + +describe('the submit route', () => { + async function post(chain: string, body: unknown): Promise { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + return POST( + new Request(`http://localhost/api/chain/${chain}/submit`, { + method: 'POST', + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chain }) } + ) + } + + it.each<[string, typeof venueAvalanche, () => ChainActionRequest]>([ + ['avalanche', venueAvalanche, () => avaxSwap()], + ['monad', venueMonad, () => swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' })], + ])('accepts { family: evm, hash } on %s and follows it', async (chain, venue, request) => { + const prepared = await venue.prepare(request(), ACCOUNT) + rpc.waitForReceipt = async () => receipt() + rpc.transaction = async () => ({ + value: BigInt((prepared.signing as Extract).value), + input: '0x', + }) + const res = await post(chain, { + account: ACCOUNT, + venueId: venue.id, + signed: { family: 'evm', hash: HASH }, + context: prepared.context, + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toMatchObject({ ok: true, hash: HASH }) + }) + + it('answers a receipt from another account as pending, which holds the latch', async () => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + rpc.waitForReceipt = async () => receipt({ from: OTHER }) + const res = await post('avalanche', { + account: ACCOUNT, + venueId: venueAvalanche.id, + signed: { family: 'evm', hash: HASH }, + context: prepared.context, + }) + expect(res.status).toBe(202) + const body = (await res.json()) as SubmitResult + expect(body).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(body, 'evm')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it.each<[string, string, unknown]>([ + ['solana bytes on Avalanche', 'avalanche', { family: 'solana', signedTransaction: 'AAAA' }], + ['a near hash on Monad', 'monad', { family: 'near', hashes: ['abc'] }], + ])('refuses %s', async (_name, chain, signed) => { + const res = await post(chain, { + account: ACCOUNT, + venueId: `1click-${chain}`, + signed, + context: {}, + }) + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'wrong_chain' }) + }) + + it('refuses an EVM answer on a chain of another family', async () => { + const res = await post('solana', { + account: ACCOUNT, + venueId: '1click-solana', + signed: { family: 'evm', hash: HASH }, + context: {}, + }) + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'wrong_chain' }) + }) +}) + +describe('a stale card, before the wallet opens', () => { + const NOW = Date.parse('2026-11-04T12:00:00.000Z') + const card = ( + family: SigningPayload['family'], + deadline?: string + ): Pick => ({ + signing: { family } as SigningPayload, + ...(deadline !== undefined ? { context: { deadline } } : {}), + }) + + it('is stopped on an EVM chain, where no later check could stop the wallet sending', () => { + expect(quoteExpiredBeforeSigning(card('evm', '2026-11-04T11:59:59.000Z'), NOW)).toBe(true) + expect(quoteExpiredBeforeSigning(card('evm', '2026-11-04T12:00:01.000Z'), NOW)).toBe(false) + expect(quoteExpiredBeforeSigning(card('evm', 'not a date'), NOW)).toBe(true) + expect(quoteExpiredBeforeSigning(card('evm'), NOW)).toBe(false) + }) + + it('says what to do in words true on both cards, since the direct one has no Review', () => { + expect(QUOTE_EXPIRED).toMatch(/nothing was sent/) + expect(QUOTE_EXPIRED).not.toMatch(/Review/) + }) + + it('is left to the server on chains whose bytes it sends, and to NEAR as it was', () => { + for (const family of ['solana', 'sui', 'near'] as const) { + expect(quoteExpiredBeforeSigning(card(family, '2020-01-01T00:00:00.000Z'), NOW)).toBe(false) + } + }) +}) + +describe('the wallet', () => { + async function adapters() { + return import('../adapters/evm-adapter') + } + + it('sends the native value as a bigint, on Monad', async () => { + const { monadAdapter } = await adapters() + const out = await monadAdapter.signPrepared?.({ + family: 'evm', + chainId: 143, + to: DEPOSIT, + data: '0x', + value: '100000000000000000000', + }) + expect(out).toEqual({ ok: true, signed: { family: 'evm', hash: WALLET_HASH } }) + expect(wallet.calls).toEqual([ + ['send', { to: DEPOSIT, data: '0x', value: BigInt('100000000000000000000'), chainId: 143 }], + ]) + }, 30_000) + + it('switches to Monad first when the wallet is elsewhere', async () => { + wallet.account = { status: 'connected', chainId: 43114 } + const { monadAdapter } = await adapters() + await monadAdapter.signPrepared?.({ + family: 'evm', + chainId: 143, + to: DEPOSIT, + data: '0x', + value: '1', + }) + expect(wallet.calls.map((c) => c[0])).toEqual(['switch', 'send']) + expect(wallet.calls[0]?.[1]).toEqual({ chainId: 143 }) + }) + + it('sends a native value on Avalanche too', async () => { + wallet.account = { status: 'connected', chainId: 43114 } + const { avalancheAdapter } = await adapters() + await avalancheAdapter.signPrepared?.({ + family: 'evm', + chainId: 43114, + to: DEPOSIT, + data: '0x', + value: ONE_AND_A_HALF_AVAX, + }) + expect(wallet.calls[0]?.[1]).toMatchObject({ value: BigInt(ONE_AND_A_HALF_AVAX) }) + }) + + it.each<[string, SigningPayload]>([ + [ + 'Avalanche’s chain id', + { family: 'evm', chainId: 43114, to: DEPOSIT, data: '0x', value: '1' }, + ], + [ + 'a value that is not decimal', + { family: 'evm', chainId: 143, to: DEPOSIT, data: '0x', value: '0x10' }, + ], + [ + 'a value that is not a number', + { family: 'evm', chainId: 143, to: DEPOSIT, data: '0x', value: 'lots' }, + ], + ['another family', { family: 'solana', transaction: 'AAAA' }], + ])('refuses %s on Monad without touching the wallet', async (_name, payload) => { + const { monadAdapter } = await adapters() + await expect(monadAdapter.signPrepared?.(payload)).resolves.toMatchObject({ + ok: false, + reason: 'wallet_error', + }) + expect(wallet.calls).toEqual([]) + }) +}) diff --git a/apps/dapp/lib/__tests__/fakes/analytics-db.ts b/apps/dapp/lib/__tests__/fakes/analytics-db.ts index a4ac272d..a8bfb8af 100644 --- a/apps/dapp/lib/__tests__/fakes/analytics-db.ts +++ b/apps/dapp/lib/__tests__/fakes/analytics-db.ts @@ -52,6 +52,7 @@ export interface FakeRace { unanimous: boolean | null outcome: string duration_ms: number + chain: string } export interface FakeProposal { @@ -110,9 +111,21 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { return { rows: [] } } - const matchRace = (id: string | null, network: string, at: string): string | null => { + const matchRace = ( + id: string | null, + network: string, + at: string, + chain: string + ): string | null => { const race = races.find((r) => r.id === id) - if (race === undefined || race.network !== network || race.winner === null) return null + if ( + race === undefined || + race.network !== network || + race.chain !== chain || + race.winner === null + ) { + return null + } const age = new Date(at).getTime() - new Date(race.started_at).getTime() return age >= 0 && age < 30 * 60_000 ? race.id : null } @@ -133,6 +146,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { amountIn, volumeUsd, raceId, + raceChain, ] = p as [ string, string, @@ -148,6 +162,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { string | null, number | null, string | null, + string, ] const duplicate = hash !== null && executions.some((e) => e.network === network && e.hash === hash) @@ -166,7 +181,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { asset_out: assetOut, amount_in: amountIn, volume_usd: volumeUsd, - race_id: matchRace(raceId, network, submittedAt), + race_id: matchRace(raceId, network, submittedAt, raceChain), }) return { rows: [{ id }] } } @@ -212,6 +227,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { unanimous, outcome, durationMs, + chain, ] = p as [ string, string, @@ -226,6 +242,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { boolean | null, string, number, + string, ] if (races.some((r) => r.id === id)) return { rows: [] } races.push({ @@ -242,6 +259,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { unanimous, outcome, duration_ms: durationMs, + chain, }) return { rows: [{ id }] } } @@ -285,13 +303,21 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { if (sql.startsWith('SELECT r.id AS race_id')) { const [network, account, limit, offset] = p as [string, string, number, number] const rows = executions - .filter((e) => e.network === network && e.account === account && e.race_id !== null) + .filter( + (e) => + e.network === network && + (e.account === account || + (/^0x[0-9a-fA-F]{40}$/.test(account) && + e.account.toLowerCase() === account.toLowerCase())) && + e.race_id !== null + ) .map((e) => { const race = races.find((r) => r.id === e.race_id) if (race === undefined) return undefined const winning = proposals.find((pr) => pr.race_id === race.id && pr.won) return { race_id: race.id, + chain: race.chain, started_at: race.started_at, token_in: race.token_in, token_out: race.token_out, diff --git a/apps/dapp/lib/__tests__/market-context-evm.test.ts b/apps/dapp/lib/__tests__/market-context-evm.test.ts new file mode 100644 index 00000000..0baa6b4b --- /dev/null +++ b/apps/dapp/lib/__tests__/market-context-evm.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +import { buildMarketContext } from '../agents/market-context' + +// Neither 1Click row claims execution today, so the family filter alone would pass by accident. +// A row that does claim it shows that one EVM chain is never handed another's venue. +vi.mock('../venues', async (original) => { + const actual = await original() + const row = (id: string, chain: string) => ({ + id, + family: 'evm' as const, + name: id, + category: 'swap' as const, + chains: [chain], + url: 'https://example.test', + bestFor: 'x', + integration: 'executes' as const, + }) + return { + ...actual, + venues: [...actual.venues, row('on-avalanche', 'Avalanche'), row('on-monad', 'Monad')], + } +}) + +describe('the market on EVM chains that share a family', () => { + it('gives each chain only the venues that name it', () => { + const ids = (chain: string): string[] => + buildMarketContext(chain, {}, 'mainnet').venues.map((v) => v.id) + expect(ids('avalanche')).toEqual(['on-avalanche']) + expect(ids('monad')).toEqual(['on-monad']) + }) +}) diff --git a/apps/dapp/lib/__tests__/near-race-quote.test.ts b/apps/dapp/lib/__tests__/near-race-quote.test.ts new file mode 100644 index 00000000..6ecb83e8 --- /dev/null +++ b/apps/dapp/lib/__tests__/near-race-quote.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { earnVenue } from '../chains/near/earn' +import { clearTokenCache } from '../chains/near/oneclick' +import { metapoolVenue, rnearVenue } from '../chains/near/stake' +import { oneClickVenue } from '../chains/near/swap' +import { METAPOOL_CONTRACT, RNEAR_CONTRACT, USDC_NEAR } from '../chains/near/tokens' +import type { ChainActionRequest } from '../chains/types' + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) + +const ME = 'alice.near' +const DEPOSIT = 'dc2c3034e3ebefa1af9a2c419f896d7b761a056244099eefcd09aeb7efc1b86d' + +type Json = Record + +interface Sim { + quotes: Json[] + override: ((body: Json) => { status: number; json: unknown } | Json | undefined) | undefined + apy: unknown + apyDown: boolean + views: Record +} + +let sim: Sim + +function goodQuote(body: Json): Json { + const amount = body['amount'] as string + const inDollars = Number(amount) / 1e6 + const outDollars = inDollars * 0.995 + const toNear = body['destinationAsset'] === 'nep141:wrap.near' + const out = toNear + ? BigInt(Math.round((outDollars / 4) * 1e6)) * BigInt('1000000000000000000') + : BigInt(Math.round(outDollars * 1e6)) * BigInt('1000000000000') + return { + quote: { + amountIn: amount, + amountInFormatted: '1', + amountInUsd: String(inDollars), + minAmountIn: amount, + amountOut: out.toString(), + amountOutFormatted: 'x', + amountOutUsd: String(outDollars), + minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(), + timeEstimate: 20, + refundFee: '0', + withdrawFee: '0', + ...(body['dry'] === true + ? {} + : { depositAddress: DEPOSIT, deadline: '2026-11-06T00:00:00.000Z' }), + }, + quoteRequest: { ...body, appFees: [{ recipient: 'fee.near', fee: 20 }] }, + signature: 'ed25519:x', + timestamp: '2026-11-03T00:00:00.000Z', + } +} + +beforeEach(() => { + clearTokenCache() + sim = { + quotes: [], + override: undefined, + apy: '4.5', + apyDown: false, + views: { + [`${RNEAR_CONTRACT}.ft_price`]: '1000000000000000000000000', + [`${METAPOOL_CONTRACT}.get_st_near_price`]: '2000000000000000000000000', + }, + } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status }) + if (url.endsWith('/get-rnear-apy')) { + if (sim.apyDown) throw new Error('down') + return json({ code: 0, data: sim.apy }) + } + if (url.endsWith('/v0/tokens')) { + const priceUpdatedAt = new Date().toISOString() + return json([ + { assetId: 'nep141:wrap.near', decimals: 24, symbol: 'wNEAR', price: 4, priceUpdatedAt }, + { assetId: `nep141:${USDC_NEAR}`, decimals: 6, symbol: 'USDC', price: 1, priceUpdatedAt }, + { + assetId: 'nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near', + decimals: 18, + symbol: 'gtUSDCp', + price: 1, + priceUpdatedAt, + }, + ]) + } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Json + sim.quotes.push(body) + const over = sim.override?.(body) + if (over !== undefined && 'status' in over) { + return json(over['json'], over['status'] as number) + } + return json(over ?? goodQuote(body), 201) + } + const rpc = JSON.parse(init?.body as string) as { + params: { account_id: string; method_name: string } + } + const key = `${rpc.params.account_id}.${rpc.params.method_name}` + if (!(key in sim.views)) return json({ result: { error: `no ${key}` } }) + return json({ result: { result: [...Buffer.from(JSON.stringify(sim.views[key]))] } }) + }) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const swap: ChainActionRequest = { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'NEAR' } +const supply: ChainActionRequest = { kind: 'supply', amount: '1', tokenIn: 'USDC' } +const stake: ChainActionRequest = { kind: 'stake', amount: '2', tokenIn: 'NEAR' } + +describe('quoting the 1Click swap for the race', () => { + it('asks for one dry quote and returns what it would give back', async () => { + const quoted = await oneClickVenue.quote?.(swap, ME) + expect(sim.quotes).toHaveLength(1) + expect(sim.quotes[0]).toMatchObject({ dry: true, originAsset: `nep141:${USDC_NEAR}` }) + expect(quoted?.receive?.symbol).toBe('NEAR') + expect(quoted?.valueOutUsd).toBeCloseTo(0.995) + }) + + it('never carries a deposit address, even if 1Click sends one', async () => { + sim.override = (body) => { + const full = goodQuote({ ...body, dry: false }) + return { ...full, quoteRequest: { ...body } } + } + const quoted = await oneClickVenue.quote?.(swap, ME) + expect(JSON.stringify(quoted)).not.toContain(DEPOSIT) + expect(quoted).not.toHaveProperty('depositAddress') + }) + + it('refuses a quote that does not say what was asked', async () => { + sim.override = (body) => { + const good = goodQuote(body) as { quoteRequest: Json } + return { ...good, quoteRequest: { ...good.quoteRequest, recipient: 'mallory.near' } } + } + await expect(oneClickVenue.quote?.(swap, ME)).rejects.toThrow(/didn't match/) + }) + + it('refuses a quote that gives back too little', async () => { + sim.override = (body) => { + const good = goodQuote(body) as { quote: Json } + return { ...good, quote: { ...good.quote, amountOutUsd: '0.5' } } + } + await expect(oneClickVenue.quote?.(swap, ME)).rejects.toThrow(/less than you pay/) + }) +}) + +describe('quoting the stake venues for the race', () => { + it('rNEAR returns the receipt amount and the rate from Rhea', async () => { + const quoted = await rnearVenue.quote?.(stake, ME) + expect(quoted).toEqual({ receive: { amount: '2', symbol: 'rNEAR' }, apyPercent: 4.5 }) + }) + + it('rNEAR gives no rate when Rhea cannot be read, rather than inventing one', async () => { + sim.apyDown = true + const quoted = await rnearVenue.quote?.(stake, ME) + expect(quoted?.receive?.symbol).toBe('rNEAR') + expect(quoted).not.toHaveProperty('apyPercent') + }) + + it('Meta Pool returns the receipt amount and no rate, because none can be read', async () => { + const quoted = await metapoolVenue.quote?.(stake, ME) + expect(quoted).toEqual({ receive: { amount: '1', symbol: 'stNEAR' } }) + }) + + it('throws when the contract cannot be read', async () => { + sim.views = {} + await expect(rnearVenue.quote?.(stake, ME)).rejects.toThrow(/could not be read/) + await expect(metapoolVenue.quote?.(stake, ME)).rejects.toThrow(/could not be read/) + }) +}) + +describe('quoting 1Click Earn for the race', () => { + it('uses a dry quote, returns the vault share, and no deposit address', async () => { + const quoted = await earnVenue.quote?.(supply, ME) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true]) + expect(quoted?.receive?.symbol).toBe('gtUSDCp') + expect(quoted).not.toHaveProperty('depositAddress') + expect(quoted).not.toHaveProperty('apyPercent') + }) +}) diff --git a/apps/dapp/lib/__tests__/near-swap.test.ts b/apps/dapp/lib/__tests__/near-swap.test.ts index bfd4beab..f6692ad3 100644 --- a/apps/dapp/lib/__tests__/near-swap.test.ts +++ b/apps/dapp/lib/__tests__/near-swap.test.ts @@ -270,6 +270,13 @@ describe('preparing a 1Click swap', () => { expect(prepared.notes.join(' ')).toMatch(/minus a fee/) }) + it('keeps NEAR’s cost sentence word for word', async () => { + const prepared = await oneClickVenue.prepare(swap(), ME) + expect(prepared.notes[0]).toMatch( + /^NEAR Intents runs on mainnet only, so this moves real funds\. .* After all costs you get about [\d.]+% less in value than you pay, and at least / + ) + }) + it('sends the API key and app fees only when configured', async () => { await oneClickVenue.prepare(swap(), ME) expect(sim.quoteHeaders[0]).not.toHaveProperty('X-API-Key') diff --git a/apps/dapp/lib/__tests__/oneclick-origin.test.ts b/apps/dapp/lib/__tests__/oneclick-origin.test.ts new file mode 100644 index 00000000..de93e3d5 --- /dev/null +++ b/apps/dapp/lib/__tests__/oneclick-origin.test.ts @@ -0,0 +1,1094 @@ +import { createHmac } from 'node:crypto' + +import { Inputs, Transaction } from '@mysten/sui/transactions' +import { normalizeSuiAddress, toBase64 } from '@mysten/sui/utils' +import { + AccountRole, + appendTransactionMessageInstructions, + compileTransaction, + createTransactionMessage, + getBase64EncodedWireTransaction, + getU64Encoder, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type Address, + type Blockhash, +} from '@solana/kit' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { clearTokenCache } from '../chains/near/oneclick' +import { oneClickAssetId, oneClickOriginVenue } from '../chains/oneclick-origin' +import { afterSubmitting } from '../chains/signing-flow' +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from '../chains/solana/assert-safe' +import { SOLANA_TOKENS } from '../chains/solana/tokens' +import { SUI_TOKENS } from '../chains/sui/tokens' +import { + ChainActionRefused, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type PreparedAction, + type SubmitResult, +} from '../chains/types' + +// The real modules are built once on load, and only offer 1Click when the secret is there. +vi.hoisted(() => { + process.env['AUTH_SECRET'] = 'x'.repeat(48) +}) + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) + +// The real submit, without the minute of polling it would otherwise wait through. +vi.mock('../chains/solana/submit', async (original) => { + const actual = await original() + return { + ...actual, + createSolanaSubmit: () => actual.createSolanaSubmit({ pollMs: 0, maxPolls: 1, env: {} }), + } +}) + +const core = vi.hoisted(() => ({ + listCoins: vi.fn(), + getBalance: vi.fn(), + getObjects: vi.fn(), + executeTransaction: vi.fn(), + resolveTransactionPlugin: vi.fn(), +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ core }), +})) + +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) + +import { verifyQuoteSignature } from '../chains/near/quote-signature' + +const token = (list: readonly ChainToken[], symbol: string): ChainToken => + tokenBySymbol(list, symbol) as ChainToken + +const SOL_USDC = token(SOLANA_TOKENS, 'USDC') +const SOL_USDT = token(SOLANA_TOKENS, 'USDT') +const SOL_NATIVE = token(SOLANA_TOKENS, 'SOL') +const SUI_USDC = token(SUI_TOKENS, 'USDC') +const SUI_USDT = token(SUI_TOKENS, 'USDT') +const SUI_NATIVE = token(SUI_TOKENS, 'SUI') + +const ME = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' +const DEPOSIT = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' +const THIEF = 'DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy' +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' +const UNITS = BigInt(25_000_000) + +const SUI_ME = normalizeSuiAddress('0xa1') +const SUI_DEPOSIT = normalizeSuiAddress('0xd1') +const SUI_THIEF = normalizeSuiAddress('0xdead') +const COIN_A = normalizeSuiAddress('0xc1') +const GAS_COIN = normalizeSuiAddress('0xf1') +const DIGEST = 'GKot5hBsd81kMupNCXHaqbhv3huEbxAFMLnpcX2hniwn' + +type Json = Record + +function tokenList(): Json[] { + const priceUpdatedAt = new Date().toISOString() + const entry = ( + assetId: string, + blockchain: string, + symbol: string, + decimals: number, + price: number, + contractAddress?: string + ): Json => ({ + assetId, + blockchain, + symbol, + decimals, + price, + priceUpdatedAt, + ...(contractAddress !== undefined ? { contractAddress } : {}), + }) + return [ + entry('nep141:sol-usdc.omft.near', 'sol', 'USDC', 6, 1, SOL_USDC.id), + entry('nep141:sol-usdt.omft.near', 'sol', 'USDT', 6, 1, SOL_USDT.id), + entry('nep141:sol.omft.near', 'sol', 'SOL', 9, 100), + // A look-alike: the right symbol on the right chain, on another contract. + entry('nep141:sol-fake.omft.near', 'sol', 'USDC', 6, 1, THIEF), + entry('nep141:sui-usdc.omft.near', 'sui', 'USDC', 6, 1, SUI_USDC.id.replace('0xdba', '0xDBA')), + entry('nep141:sui-usdt.omft.near', 'sui', 'USDT', 6, 1, SUI_USDT.id), + entry('nep141:sui.omft.near', 'sui', 'SUI', 9, 2), + entry('nep141:eth-usdc.omft.near', 'eth', 'USDC', 6, 1, SOL_USDC.id), + ] +} + +interface Sim { + tokens: Json[] + quotes: Json[] + notified: Json[] + rpc: string[] + statuses: (string | undefined)[] + statusRecipient: string | undefined + quoteOverride: (body: Json) => Json | undefined + send: () => unknown + depositAddress: string +} + +let sim: Sim + +function goodQuote(body: Json): Json { + const amount = body['amount'] as string + // Priced to match the list: SOL at $100 and SUI at $2, against dollars with six places. + const scale: Record = { + 'nep141:sol.omft.near': 10, + 'nep141:sui.omft.near': 500, + } + const out = BigInt(amount) * BigInt(scale[body['destinationAsset'] as string] ?? 1) + return { + quote: { + amountIn: amount, + amountInFormatted: '25', + amountInUsd: String(Number(amount) / 1e6), + minAmountIn: amount, + amountOut: out.toString(), + amountOutFormatted: 'x', + amountOutUsd: String((Number(amount) / 1e6) * 0.995), + minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(), + ...(body['dry'] === true ? {} : { depositAddress: sim.depositAddress }), + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + } +} + +function installFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status }) + if (url.endsWith('/v0/tokens')) return json(sim.tokens) + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Json + sim.quotes.push(body) + return json(sim.quoteOverride(body) ?? goodQuote(body), 201) + } + if (url.endsWith('/v0/deposit/submit')) { + sim.notified.push(JSON.parse(init?.body as string) as Json) + return json({ correlationId: 'c' }) + } + if (url.includes('/v0/status')) { + const status = sim.statuses.length > 1 ? sim.statuses.shift() : sim.statuses[0] + return json({ + status, + quoteResponse: { quoteRequest: { recipient: sim.statusRecipient } }, + }) + } + const { method } = JSON.parse(init?.body as string) as { method: string } + sim.rpc.push(method) + const replies: Record = { + getAccountInfo: { result: { context: { slot: 1 }, value: null } }, + getLatestBlockhash: { + result: { + context: { slot: 1 }, + value: { blockhash: BLOCKHASH, lastValidBlockHeight: 9 }, + }, + }, + sendTransaction: method === 'sendTransaction' ? await sim.send() : undefined, + getSignatureStatuses: { + result: { value: [{ confirmationStatus: 'confirmed', err: null }] }, + }, + isBlockhashValid: { result: { value: true } }, + } + return json({ jsonrpc: '2.0', id: 1, ...(replies[method] as object) }) + }) + ) +} + +const swap = (over: Partial = {}): ChainActionRequest => ({ + kind: 'swap', + amount: '25', + tokenIn: 'USDC', + tokenOut: 'SOL', + ...over, +}) + +const suiSwap = (over: Partial = {}): ChainActionRequest => + swap({ tokenOut: 'SUI', ...over }) + +const solana = oneClickOriginVenue('solana') +const sui = oneClickOriginVenue('sui') + +interface TransferFields { + mint?: string + to?: string + amount?: bigint + feePayer?: string + extra?: boolean + owner?: string +} + +/** A signed-shaped Solana transaction with chosen fields, the way a tampered browser would. */ +function solanaTx(fields: TransferFields = {}): string { + const mint = fields.mint ?? SOL_USDC.id + const to = fields.to ?? DEPOSIT + const owner = fields.owner ?? ME + const destination = associatedTokenAccount(to, mint) + const transfer = (amount: bigint, target: string) => ({ + programAddress: TOKEN_PROGRAM as Address, + accounts: [ + { address: associatedTokenAccount(owner, mint) as Address, role: AccountRole.WRITABLE }, + { address: mint as Address, role: AccountRole.READONLY }, + { address: target as Address, role: AccountRole.WRITABLE }, + { address: owner as Address, role: AccountRole.READONLY_SIGNER }, + ], + data: Uint8Array.from([12, ...getU64Encoder().encode(amount), 6]), + }) + const instructions = [ + { + programAddress: ASSOCIATED_TOKEN_PROGRAM as Address, + accounts: [ + { address: owner as Address, role: AccountRole.WRITABLE_SIGNER }, + { address: destination as Address, role: AccountRole.WRITABLE }, + { address: to as Address, role: AccountRole.READONLY }, + { address: mint as Address, role: AccountRole.READONLY }, + { address: SYSTEM_PROGRAM as Address, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM as Address, role: AccountRole.READONLY }, + ], + data: Uint8Array.of(1), + }, + transfer(fields.amount ?? UNITS, destination), + ...(fields.extra === true ? [transfer(BigInt(1), associatedTokenAccount(THIEF, mint))] : []), + ] + const message = pipe( + createTransactionMessage({ version: 'legacy' }), + (m) => setTransactionMessageFeePayer((fields.feePayer ?? owner) as Address, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: BLOCKHASH as Blockhash, lastValidBlockHeight: BigInt(9) }, + m + ), + (m) => appendTransactionMessageInstructions(instructions as never, m) + ) + return getBase64EncodedWireTransaction(compileTransaction(message as never)) +} + +/** What a wallet hands back: the same bytes with the fee payer's signature filled in. */ +function signedBytes(base64: string): string { + const bytes = Buffer.from(base64, 'base64') + bytes.fill(7, 1, 65) + return bytes.toString('base64') +} + +function suiTx(fields: { to?: string; amount?: bigint; gasOwner?: string; extra?: boolean } = {}) { + const tx = new Transaction() + tx.setSender(SUI_ME) + tx.setGasOwner(fields.gasOwner ?? SUI_ME) + tx.setGasPrice(1000) + tx.setGasBudget(1_000_000) + tx.setGasPayment([{ objectId: GAS_COIN, version: '10', digest: DIGEST }]) + const coin = tx.object(Inputs.ObjectRef({ objectId: COIN_A, version: '10', digest: DIGEST })) + const [split] = tx.splitCoins(coin, [fields.amount ?? UNITS]) + tx.transferObjects([split as never], fields.to ?? SUI_DEPOSIT) + if (fields.extra === true) { + const [more] = tx.splitCoins(coin, [BigInt(1)]) + tx.transferObjects([more as never], SUI_THIEF) + } + return tx.build().then(toBase64) +} + +function coinObject(objectId: string, type = SUI_USDC.id, owner = SUI_ME) { + return { + objectId, + version: '10', + digest: DIGEST, + owner: { $kind: 'AddressOwner', AddressOwner: owner }, + type: `0x2::coin::Coin<${type}>`, + } +} + +beforeEach(() => { + clearTokenCache() + sim = { + tokens: tokenList(), + quotes: [], + notified: [], + rpc: [], + statuses: ['SUCCESS'], + statusRecipient: ME, + quoteOverride: () => undefined, + send: () => ({ result: 'sent' }), + depositAddress: DEPOSIT, + } + installFetch() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) + vi.mocked(verifyQuoteSignature).mockReturnValue(true) + for (const mock of Object.values(core)) mock.mockReset() + core.resolveTransactionPlugin.mockReturnValue( + async ( + data: { gasData: { price: unknown; budget: unknown; payment: unknown } }, + _options: unknown, + next: () => Promise + ) => { + data.gasData.price = '1000' + data.gasData.budget = '1000000' + data.gasData.payment = [{ objectId: GAS_COIN, version: '10', digest: DIGEST }] + await next() + } + ) + core.getObjects.mockImplementation(async ({ objectIds }: { objectIds: string[] }) => ({ + objects: objectIds.map((id) => coinObject(id)), + })) + core.listCoins.mockImplementation(async ({ coinType }: { coinType: string }) => ({ + objects: [{ ...coinObject(COIN_A, coinType), balance: '90000000' }], + hasNextPage: false, + cursor: null, + })) + core.executeTransaction.mockResolvedValue({ + Transaction: { digest: 'DIGESTx', effects: { status: { success: true } } }, + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.useRealTimers() +}) + +describe('oneClickAssetId', () => { + it('matches on chain and contract', async () => { + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBe('nep141:sol-usdc.omft.near') + await expect(oneClickAssetId('solana', SOL_USDT)).resolves.toBe('nep141:sol-usdt.omft.near') + }) + + it('refuses a look-alike symbol on another contract', async () => { + const lookAlike = { + symbol: 'USDC', + id: 'So11111111111111111111111111111111111111113', + decimals: 6, + } + await expect(oneClickAssetId('solana', lookAlike)).resolves.toBeUndefined() + }) + + it('refuses the right contract when the list has it only on another chain', async () => { + sim.tokens = tokenList().filter((t) => t['blockchain'] !== 'sol') + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('never folds case on a base58 mint, where case is the address', async () => { + sim.tokens = [{ ...tokenList()[0], contractAddress: SOL_USDC.id.toLowerCase() }] + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('reads hex in any case', async () => { + await expect(oneClickAssetId('sui', SUI_USDC)).resolves.toBe('nep141:sui-usdc.omft.near') + }) + + it('refuses an entry whose decimals disagree with the token', async () => { + sim.tokens = [{ ...tokenList()[0], decimals: 9 }] + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('refuses a contract the list carries twice, since it could not say which is real', async () => { + const first = tokenList()[0] as Json + sim.tokens = [first, { ...first, assetId: 'nep141:other.omft.near' }] + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('finds a native coin listed without a contract, but only the native coin', async () => { + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBe('nep141:sui.omft.near') + sim.tokens = [{ ...tokenList()[2], symbol: 'USDC', decimals: 6 }] + clearTokenCache() + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('never pays out wrapped SOL for SOL, even when 1Click lists the wrapped mint', async () => { + const native = tokenList()[2] as Json + const wrapped = { ...native, assetId: 'nep141:wsol.omft.near', contractAddress: SOL_NATIVE.id } + sim.tokens = [wrapped] + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBeUndefined() + clearTokenCache() + sim.tokens = [wrapped, native] + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') + }) + + it('refuses a swap into SOL when only the wrapped mint is listed', async () => { + const native = tokenList()[2] as Json + sim.tokens = [ + ...tokenList().filter((t) => t['assetId'] !== native['assetId']), + { ...native, assetId: 'nep141:wsol.omft.near', contractAddress: SOL_NATIVE.id }, + ] + await expect(solana.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.quotes).toHaveLength(0) + }) + + it('takes SUI under its own coin type, which is the native coin itself', async () => { + const native = tokenList()[6] as Json + sim.tokens = [{ ...native, contractAddress: `0x${'2'.padStart(64, '0')}::sui::SUI` }] + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBe('nep141:sui.omft.near') + }) + + it('never takes another coin called SUI for SUI', async () => { + const native = tokenList()[6] as Json + const wrapped = { + ...native, + assetId: 'nep141:wsui.omft.near', + contractAddress: `${normalizeSuiAddress('0xbad')}::wsui::SUI`, + } + sim.tokens = [wrapped] + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBeUndefined() + clearTokenCache() + sim.tokens = [wrapped, native] + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBe('nep141:sui.omft.near') + }) + + it('still reads a list whose contract is null, so NEAR prices never break on it', async () => { + sim.tokens = [{ ...tokenList()[0] }, { ...tokenList()[2], contractAddress: null }] + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') + }) +}) + +describe('quote', () => { + it('asks only for a dry quote and gives back what the swap would return', async () => { + const quoted = await solana.quote?.(swap(), ME) + expect(quoted).toEqual({ + receive: { amount: '0.25', symbol: 'SOL' }, + walletCost: { amount: '0.00203928', symbol: 'SOL' }, + }) + expect(quoted).not.toHaveProperty('valueOutUsd') + expect(sim.quotes).toHaveLength(1) + expect(sim.quotes[0]).toMatchObject({ + dry: true, + originAsset: 'nep141:sol-usdc.omft.near', + destinationAsset: 'nep141:sol.omft.near', + amount: '25000000', + recipient: ME, + recipientType: 'DESTINATION_CHAIN', + refundTo: ME, + refundType: 'ORIGIN_CHAIN', + }) + expect(JSON.stringify(quoted)).not.toContain(DEPOSIT) + }) + + it('has no rent to report on Sui, and leaves the valuing to the chain there too', async () => { + await expect(sui.quote?.(suiSwap(), SUI_ME)).resolves.toEqual({ + receive: { amount: '12.5', symbol: 'SUI' }, + }) + }) + + it.each([ + ['solana', 'SOL', 'USDC', ME], + ['sui', 'SUI', 'USDC', SUI_ME], + ] as const)( + 'refuses native %s input in a plain sentence, before any quote', + async (chain, tokenIn, tokenOut, account) => { + const attempt = oneClickOriginVenue(chain).quote?.(swap({ tokenIn, tokenOut }), account) + await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused) + await expect(attempt).rejects.toThrow(/USDC or USDT/) + expect(sim.quotes).toHaveLength(0) + } + ) + + it('refuses any other input, such as JitoSOL', async () => { + await expect( + solana.quote?.(swap({ tokenIn: 'JitoSOL', tokenOut: 'USDC' }), ME) + ).rejects.toThrow(/USDC or USDT/) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses an output 1Click does not list, so the race drops the route', async () => { + await expect(solana.quote?.(swap({ tokenOut: 'ONyc' }), ME)).rejects.toThrow(/ONyc/) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses USDT on Sui when 1Click does not list it', async () => { + sim.tokens = tokenList().filter((t) => t['assetId'] !== 'nep141:sui-usdt.omft.near') + await expect(sui.quote?.(suiSwap({ tokenIn: 'USDT' }), SUI_ME)).rejects.toBeInstanceOf( + ChainActionRefused + ) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses another chain’s account, and a swap to the same token', async () => { + await expect(solana.quote?.(swap(), 'alice.near')).rejects.toBeInstanceOf(ChainActionRefused) + await expect(solana.quote?.(swap({ tokenOut: 'USDC' }), ME)).rejects.toThrow(/same token/) + }) + + it('offers no route when there is no secret to protect what it would issue', async () => { + vi.stubEnv('AUTH_SECRET', '') + await expect(solana.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.quotes).toHaveLength(0) + }) +}) + +describe('prepare', () => { + it('runs a dry quote and only then a real one, with a five-minute deadline', async () => { + await solana.prepare(swap(), ME) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true, false]) + const minutes = (Date.parse(sim.quotes[1]?.['deadline'] as string) - Date.now()) / 60_000 + expect(minutes).toBeGreaterThan(3) + expect(minutes).toBeLessThanOrEqual(5) + }) + + it('builds one transfer of exactly the quoted amount, which passes assertSafe', async () => { + const prepared = await solana.prepare(swap(), ME) + expect(prepared).toMatchObject({ + venueId: '1click-solana', + kind: 'swap', + pay: { amount: '25', symbol: 'USDC' }, + receive: { amount: '0.25', symbol: 'SOL', approximate: true }, + }) + expect(prepared.signing.family).toBe('solana') + expect(prepared.context?.['depositAddress']).toBe(DEPOSIT) + expect(() => solana.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow() + }) + + it('tells the truth about costs on Solana, including rent it can not get back', async () => { + const { notes } = await solana.prepare(swap(), ME) + const text = notes.join(' ') + expect(text).toMatch(/0\.00204 SOL/) + expect(text).toMatch(/rent/) + expect(text).toMatch(/don't get it back/) + expect(text).not.toMatch(/after all costs/i) + expect(text).toMatch(/before your wallet's network costs/) + }) + + it('still gives the rent note when SOL has no price anywhere', async () => { + sim.tokens = tokenList().map((t) => + t['assetId'] === 'nep141:sol.omft.near' ? { ...t, price: undefined } : t + ) + const { notes } = await solana.prepare(swap({ tokenOut: 'USDT' }), ME) + expect(notes.join(' ')).toMatch(/0\.00204 SOL/) + }) + + it('says nothing about rent on Sui, which has none, and no NEAR registration', async () => { + sim.depositAddress = SUI_DEPOSIT + sim.quoteOverride = (body) => { + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), withdrawFee: '1000' } } + } + const text = (await sui.prepare(suiSwap(), SUI_ME)).notes.join(' ') + expect(text).not.toMatch(/rent/) + expect(text).not.toMatch(/after all costs/i) + expect(text).not.toMatch(/register/) + expect(text).toMatch(/to deliver it to you/) + }) + + it('spends the token asked for, not USDC by assumption', async () => { + const prepared = await solana.prepare(swap({ tokenIn: 'USDT' }), ME) + expect(() => solana.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow() + if (prepared.signing.family !== 'solana') throw new Error('wrong family') + const usdtTransfer = solanaTx({ mint: SOL_USDT.id }) + expect(() => + solana.assertSafe({ family: 'solana', transaction: usdtTransfer }, ME, prepared.context) + ).not.toThrow() + expect(() => + solana.assertSafe({ family: 'solana', transaction: solanaTx() }, ME, prepared.context) + ).toThrow(ChainActionRefused) + }) + + it('refuses a deposit memo, and builds nothing', async () => { + sim.quoteOverride = (body) => { + if (body['dry'] === true) return undefined + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), depositMemo: '12345' } } + } + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/memo/i) + expect(sim.rpc).toHaveLength(0) + }) + + it.each([ + ['an amount other than what was asked', { amountIn: '24000000' }], + ['a smaller minimum in', { minAmountIn: '1' }], + ['a minimum out past the slippage allowed', { minAmountOut: '1' }], + ])('refuses a real quote with %s', async (_name, change) => { + sim.quoteOverride = (body) => { + if (body['dry'] === true) return undefined + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), ...change } } + } + await expect(solana.prepare(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.rpc).toHaveLength(0) + }) + + it.each([ + ['recipient', { recipient: THIEF }], + ['refund address', { refundTo: THIEF }], + ['refund type', { refundType: 'INTENTS' }], + ])('refuses a real quote naming another %s', async (_name, change) => { + sim.quoteOverride = (body) => + body['dry'] === true + ? undefined + : { ...goodQuote(body), quoteRequest: { ...body, ...change } } + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/didn't match/) + }) + + it('refuses a quote that fails its signature check', async () => { + vi.mocked(verifyQuoteSignature).mockReturnValue(false) + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/signature/) + }) + + it('refuses a quote worth far less than it costs', async () => { + sim.quoteOverride = (body) => { + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), amountOutUsd: '1' } } + } + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/less than you pay/) + }) + + it('refuses a deposit address that is your own wallet or not an address', async () => { + sim.depositAddress = ME + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/your own wallet/) + sim.depositAddress = 'not-an-address' + await expect(solana.prepare(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + }) + + it('builds the Sui transfer from coins of the token asked for', async () => { + sim.depositAddress = SUI_DEPOSIT + sim.statusRecipient = SUI_ME + const prepared = await sui.prepare(suiSwap({ tokenIn: 'USDT' }), SUI_ME) + expect(core.listCoins.mock.calls[0]?.[0]).toMatchObject({ coinType: SUI_USDT.id }) + expect(prepared.venueId).toBe('1click-sui') + expect(() => sui.assertSafe(prepared.signing, SUI_ME, prepared.context)).not.toThrow() + }) +}) + +describe('assertSafe on Solana', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await solana.prepare(swap(), ME) + }) + + const check = (transaction: string, context = prepared.context, account = ME): void => + solana.assertSafe({ family: 'solana', transaction }, account, context) + + it('admits the one transfer that was issued', () => { + expect(() => check(solanaTx())).not.toThrow() + }) + + it.each<[string, TransferFields]>([ + ['a wrong recipient', { to: THIEF }], + ['a wrong amount', { amount: UNITS - BigInt(1) }], + ['an extra transfer', { extra: true }], + ['another token', { mint: SOL_USDT.id }], + ['a fee payer other than the account', { feePayer: THIEF }], + ])('refuses %s', (_name, fields) => { + expect(() => check(solanaTx(fields))).toThrow(ChainActionRefused) + }) +}) + +describe('assertSafe on Sui', () => { + let prepared: PreparedAction + + beforeEach(async () => { + sim.depositAddress = SUI_DEPOSIT + prepared = await sui.prepare(suiSwap(), SUI_ME) + }) + + const check = (transaction: string): void => + sui.assertSafe({ family: 'sui', transaction }, SUI_ME, prepared.context) + + it('admits the one transfer that was issued', async () => { + const bytes = await suiTx() + expect(() => check(bytes)).not.toThrow() + }) + + it.each([ + ['a wrong recipient', { to: SUI_THIEF }], + ['a wrong amount', { amount: UNITS - BigInt(1) }], + ['an extra transfer', { extra: true }], + ['a gas owner other than the account', { gasOwner: SUI_THIEF }], + ])('refuses %s', async (_name, fields) => { + const bytes = await suiTx(fields) + expect(() => check(bytes)).toThrow(ChainActionRefused) + }) +}) + +describe('a browser that changes what it sends back', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await solana.prepare(swap(), ME) + }) + + const payload = (fields: TransferFields = {}) => + ({ family: 'solana', transaction: solanaTx(fields) }) as const + + it('cannot point the deposit at its own address', () => { + const context = { ...prepared.context, depositAddress: THIEF } + expect(() => solana.assertSafe(payload({ to: THIEF }), ME, context)).toThrow(ChainActionRefused) + }) + + it('cannot change the amount or the token with the bytes to match', () => { + const smaller = { ...prepared.context, amount: '1' } + expect(() => solana.assertSafe(payload({ amount: BigInt(1) }), ME, smaller)).toThrow( + ChainActionRefused + ) + const otherToken = { ...prepared.context, token: SOL_USDT.id } + expect(() => solana.assertSafe(payload({ mint: SOL_USDT.id }), ME, otherToken)).toThrow( + ChainActionRefused + ) + }) + + it('cannot extend the deadline', () => { + const later = { ...prepared.context, deadline: '2099-01-01T00:00:00.000Z' } + expect(() => solana.assertSafe(payload(), ME, later)).toThrow(ChainActionRefused) + }) + + it('cannot leave the context out or empty', () => { + expect(() => solana.assertSafe(payload(), ME)).toThrow(ChainActionRefused) + expect(() => solana.assertSafe(payload(), ME, {})).toThrow(ChainActionRefused) + }) + + it('cannot forge a proof without the server’s secret', () => { + const context: Record = { ...prepared.context, depositAddress: THIEF } + const issued = JSON.stringify([ + '1click-solana', + ME, + SOL_USDC.id, + '25000000', + THIEF, + context['deadline'], + ]) + const proof = createHmac('sha256', 'y'.repeat(48)).update(issued).digest('base64url') + expect(() => solana.assertSafe(payload({ to: THIEF }), ME, { ...context, proof })).toThrow( + ChainActionRefused + ) + }) + + describe('with the right key', () => { + const signWith = (message: string): string => + createHmac('sha256', 'x'.repeat(48)).update(message).digest('base64url') + + const issuedMessage = (context: Record): string => + JSON.stringify([ + '1click-solana', + ME, + SOL_USDC.id, + '25000000', + context['depositAddress'], + context['deadline'], + ]) + + it('rebuilds the real proof, so the case below tests only the prefix', () => { + const context: Record = { ...prepared.context } + expect(signWith(`oneclick-origin:${issuedMessage(context)}`)).toBe(context['proof']) + }) + + it('refuses a signature made without the prefix, as any other use of the key would be', () => { + const context: Record = { ...prepared.context, depositAddress: THIEF } + const proof = signWith(issuedMessage(context)) + expect(() => solana.assertSafe(payload({ to: THIEF }), ME, { ...context, proof })).toThrow( + /not what this app issued/ + ) + }) + }) + + it('cannot reuse a proof for another account or another chain', async () => { + // Bytes that are a perfect transfer from the other account, so only the proof can refuse them. + expect(() => solana.assertSafe(payload({ owner: THIEF }), THIEF, prepared.context)).toThrow( + /not what this app issued/ + ) + expect(() => + sui.assertSafe({ family: 'sui', transaction: 'AAAA' }, SUI_ME, prepared.context) + ).toThrow(/not what this app issued/) + }) + + it('cannot spend a quote after its deadline', () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(Date.now() + 6 * 60_000) + expect(() => solana.assertSafe(payload(), ME, prepared.context)).toThrow(/expired/) + }) + + it.each([ + ['solana', { family: 'solana' as const, signedTransaction: '!!!' }], + ['sui', { family: 'sui' as const, transaction: '!!!', signature: 'x' }], + ])('stays a clean refusal on %s for bytes too broken to read', async (chain, signed) => { + const venue = chain === 'solana' ? solana : sui + const result = await venue.submit?.({ + account: chain === 'solana' ? ME : SUI_ME, + venueId: venue.id, + signed, + context: {}, + }) + expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/not what this app/) }) + expect(result).not.toHaveProperty('pending') + }) + + it('is refused when the server no longer holds the secret', () => { + vi.stubEnv('AUTH_SECRET', '') + expect(() => solana.assertSafe(payload(), ME, prepared.context)).toThrow(ChainActionRefused) + }) + + it('sends nothing from submit either', async () => { + const context = { ...prepared.context, depositAddress: THIEF } + const result = await solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx({ to: THIEF })) }, + context, + }) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(sim.rpc).not.toContain('sendTransaction') + }) + + it('is stopped by the real route, which hands assertSafe the context', async () => { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + const res = await POST( + new Request('http://localhost/api/chain/solana/submit', { + method: 'POST', + body: JSON.stringify({ + account: ME, + venueId: '1click-solana', + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx({ to: THIEF })) }, + context: { ...prepared.context, depositAddress: THIEF }, + }), + }), + { params: Promise.resolve({ chain: 'solana' }) } + ) + expect(res.status).toBe(422) + expect(sim.rpc).not.toContain('sendTransaction') + }) +}) + +describe('the submit route', () => { + it('gives a submit the 60 seconds it waits within', async () => { + const route = await import('../../app/api/chain/[chain]/submit/route') + expect(route.maxDuration).toBe(60) + }) + + it('hands assertSafe the context, so an honest swap gets through', async () => { + const prepared = await solana.prepare(swap(), ME) + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + const res = await POST( + new Request('http://localhost/api/chain/solana/submit', { + method: 'POST', + body: JSON.stringify({ + account: ME, + venueId: '1click-solana', + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx()) }, + context: prepared.context, + }), + }), + { params: Promise.resolve({ chain: 'solana' }) } + ) + expect(res.status).toBe(200) + expect(sim.rpc).toContain('sendTransaction') + }) +}) + +describe('submit on Solana', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await solana.prepare(swap(), ME) + }) + + async function run(fields: TransferFields = {}): Promise { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const pending = solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx(fields)) }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.runAllTimersAsync() + return pending + } + + it('returns ok once 1Click reports success, and tells it about the deposit', async () => { + const result = await run() + expect(result).toMatchObject({ ok: true }) + expect(sim.rpc).toContain('sendTransaction') + expect(sim.notified).toHaveLength(1) + expect(sim.notified[0]).toMatchObject({ depositAddress: DEPOSIT }) + expect(sim.notified[0]).not.toHaveProperty('nearSenderAccount') + }) + + it('keeps following while the swap runs', async () => { + sim.statuses = ['PENDING_DEPOSIT', 'PROCESSING', 'SUCCESS'] + await expect(run()).resolves.toMatchObject({ ok: true }) + }) + + it.each([ + ['a swap still running', 'PROCESSING', /still processing/i], + ['a swap not seen yet', 'PENDING_DEPOSIT', /still processing/i], + ['a refund', 'REFUNDED', new RegExp(ME)], + ['a failed swap', 'FAILED', new RegExp(ME)], + ['too little received', 'INCOMPLETE_DEPOSIT', new RegExp(ME)], + ['an unknown status', 'SOMETHING_NEW', /still processing/i], + ])('reports %s as pending, holding the latch', async (_name, status, sentence) => { + sim.statuses = [status] + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + if (result.ok) return + expect(result.error).toMatch(sentence) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('reports a deposit address 1Click ties to someone else as pending', async () => { + sim.statusRecipient = THIEF + await expect(run()).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it('stops following within the route’s 60 seconds', async () => { + sim.statuses = ['PROCESSING'] + const startedAt = Date.now() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: startedAt }) + const pending = solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx()) }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.runAllTimersAsync() + await pending + expect(Date.now() - startedAt).toBeLessThan(60_000) + }) + + it('answers pending if the network has not answered by the time limit', async () => { + sim.send = () => new Promise(() => undefined) + const startedAt = Date.now() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: startedAt }) + const pending = solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx()) }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.advanceTimersByTimeAsync(55_000) + const result = await pending + expect(result).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + expect(sim.notified).toHaveLength(0) + }) + + it('passes on a clean failure the network proved, without asking 1Click', async () => { + sim.send = () => ({ error: { code: -32002, message: 'simulation failed', data: {} } }) + const result = await run() + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + expect(sim.notified).toHaveLength(0) + }) + + it('reports an unclear send answer as pending, without asking 1Click', async () => { + sim.send = () => ({ error: { code: -32000, message: 'gateway said something' } }) + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toContain(DEPOSIT) + expect(sim.notified).toHaveLength(0) + }) + + it('reports a dropped connection during the send as pending', async () => { + sim.send = () => { + throw new Error('connection reset') + } + await expect(run()).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it('sends nothing the wallet left unsigned', async () => { + const result = await solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: solanaTx() }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(sim.rpc).not.toContain('sendTransaction') + }) + + it('refuses another chain’s signature without sending', async () => { + const result = await solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'sui', transaction: 'AAAA', signature: 'sig' }, + }) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + }) +}) + +describe('submit on Sui', () => { + let prepared: PreparedAction + + beforeEach(async () => { + sim.depositAddress = SUI_DEPOSIT + sim.statusRecipient = SUI_ME + prepared = await sui.prepare(suiSwap(), SUI_ME) + }) + + async function run(): Promise { + const transaction = await suiTx() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const pending = sui.submit?.({ + account: SUI_ME, + venueId: sui.id, + signed: { family: 'sui', transaction, signature: 'sig' }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.runAllTimersAsync() + return pending + } + + it('returns ok on success, with the Sui explorer link', async () => { + const result = await run() + expect(result).toEqual({ + ok: true, + hash: 'DIGESTx', + explorerUrl: 'https://suiscan.xyz/mainnet/tx/DIGESTx', + }) + expect(sim.notified[0]).toMatchObject({ txHash: 'DIGESTx', depositAddress: SUI_DEPOSIT }) + }) + + it('refuses a coin of another type, which the bytes cannot show, without sending', async () => { + core.getObjects.mockResolvedValue({ objects: [coinObject(COIN_A, SUI_USDT.id)] }) + const result = await run() + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(afterSubmitting(result, 'sui')).toMatchObject({ phase: 'failed', latched: false }) + expect(core.executeTransaction).not.toHaveBeenCalled() + }) + + it('passes on a failure the network proved, in the words of the token', async () => { + core.executeTransaction.mockResolvedValue({ + FailedTransaction: { digest: 'DIGESTx', effects: { status: { success: false } } }, + }) + const result = await run() + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + if (!result.ok) expect(result.error).toMatch(/USDC did not move/) + expect(sim.notified).toHaveLength(0) + }) + + it('reports a broken connection during the send as pending', async () => { + core.executeTransaction.mockRejectedValue(new Error('deadline exceeded')) + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(result, 'sui')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it.each(['REFUNDED', 'FAILED', 'INCOMPLETE_DEPOSIT'])( + 'reports %s after the transfer as pending, naming where funds return', + async (status) => { + sim.statuses = [status] + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toContain(SUI_ME) + } + ) +}) diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts new file mode 100644 index 00000000..575e44d6 --- /dev/null +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { capabilitySheet } from '../orchestrate/capabilities' + +describe('capabilitySheet per chain', () => { + it('keeps Stellar as it was', () => { + const sheet = capabilitySheet('stellar') + expect(sheet).toContain('limit order') + expect(sheet).toContain('Accumulate') + expect(sheet).toContain('Send:') + }) + + it('says agents compete on NEAR and lists what NEAR can do', () => { + const sheet = capabilitySheet('near') + expect(sheet).toContain('Agents compete') + expect(sheet).toContain('Supply:') + expect(sheet).toContain('Stake:') + expect(sheet).not.toMatch(/Send:|Accumulate|Hedge|limit order/) + }) + + it('says agents compete on Solana and Sui now their venues can be quoted', () => { + expect(capabilitySheet('solana')).toContain('Agents compete') + expect(capabilitySheet('sui')).toContain('Agents compete') + expect(capabilitySheet('solana')).not.toContain('Stake:') + }) + + it('leaves a chain with no module as it was', () => { + expect(capabilitySheet('arc')).toBe( + '- Swap: trade one asset for another at the market price. You review it and sign in your wallet.' + ) + }) +}) + +describe('capabilitySheet reads what the chain can run on this server', () => { + afterEach(() => { + vi.doUnmock('../chains/registry') + vi.unstubAllEnvs() + vi.resetModules() + }) + + /** The real registry, built from scratch with this secret, as a server starting up would. */ + async function sheetWith(secret: string): Promise { + vi.resetModules() + vi.stubEnv('AUTH_SECRET', secret) + return (await import('../orchestrate/capabilities')).capabilitySheet + } + + it('promises nothing on Avalanche and Monad with no secret to issue a deposit', async () => { + const sheet = await sheetWith('') + for (const chain of ['avalanche', 'monad']) { + expect(sheet(chain)).toBe( + '- Nothing can be swapped, supplied or staked on this chain right now.' + ) + } + }) + + it('promises the race on them once the secret is set', async () => { + const sheet = await sheetWith('x'.repeat(48)) + for (const chain of ['avalanche', 'monad']) { + expect(sheet(chain)).toContain('Swap:') + expect(sheet(chain)).toContain('Agents compete') + expect(sheet(chain)).not.toMatch(/Supply:|Stake:/) + } + }, 60_000) + + it('still races Solana and Sui with no secret, on their own venues', async () => { + const sheet = await sheetWith('') + expect(sheet('solana')).toMatch(/Swap:.*Agents compete/) + expect(sheet('sui')).toMatch(/Swap:.*Agents compete/) + }, 60_000) + + it('promises no race for a venue that gives no quote, and says the rest plainly', async () => { + vi.resetModules() + vi.doMock('../chains/registry', () => ({ + chainModule: () => ({ venues: [{ kinds: ['swap'] }, { kinds: ['stake'], quote: vi.fn() }] }), + })) + const { capabilitySheet: sheet } = await import('../orchestrate/capabilities') + const lines = sheet('near').split(/\r?\n/) + expect(lines[0]).toMatch(/^- Swap:.*You review it/) + expect(lines[0]).not.toContain('Agents compete') + expect(lines[1]).toMatch(/^- Stake:.*Agents compete/) + expect(lines).toHaveLength(2) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-submit.test.ts b/apps/dapp/lib/__tests__/solana-submit.test.ts index e337f63d..267ab5e8 100644 --- a/apps/dapp/lib/__tests__/solana-submit.test.ts +++ b/apps/dapp/lib/__tests__/solana-submit.test.ts @@ -1,7 +1,9 @@ import { getBase58Decoder } from '@solana/kit' import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterSubmitting } from '../chains/signing-flow' import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap' +import { solanaModule } from '../chains/solana/module' import { createSolanaSubmit } from '../chains/solana/submit' import { CAPTURE_ACCOUNT, @@ -173,7 +175,7 @@ describe('solana submit', () => { }) const result = await submit({}, 3)(input(signed.transaction)) expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/expired/i) }) - expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(3) + expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(4) }) it('does not claim failure while the transaction could still land', async () => { @@ -183,7 +185,11 @@ describe('solana submit', () => { return status(null) }) const result = await submit({}, 2)(input(signed.transaction)) - expect(result).toMatchObject({ ok: false, error: expect.stringContaining('solscan.io') }) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringContaining('solscan.io'), + }) expect(result).toMatchObject({ error: expect.stringMatching(/before trying again/i) }) }) @@ -209,6 +215,266 @@ describe('solana submit', () => { }) await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ ok: true }) }) + + describe('blockhash invalid after the send was accepted', () => { + const EXPIRED = + 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.' + const THROWS = Symbol('the read never got an answer') + const RPC_ERROR = Symbol('the read was answered with a JSON-RPC error') + const EMPTY = Symbol('the read was answered with an empty list') + const processed = { confirmationStatus: 'processed', err: null } + const confirmed = { confirmationStatus: 'confirmed', err: null } + + /** Three polls and then the final read, each answered in turn. */ + function reads(answers: unknown[], blockhashValid = false) { + let read = 0 + return stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + if (method === 'isBlockhashValid') + return ok({ context: { slot: 1 }, value: blockhashValid }) + const answer = answers[read] + read += 1 + if (answer === THROWS) throw new Error('socket hang up') + if (answer === RPC_ERROR) return { error: { code: -32005, message: 'Node is behind' } } + if (answer === EMPTY) return ok({ context: { slot: 1 }, value: [] }) + return status(answer) + }) + } + + it('stays pending when a poll failed while the transaction landed', async () => { + reads([THROWS, null, null, null]) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).not.toContain('nothing moved') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it.each([ + ['every poll and the final read throw', [THROWS, THROWS, THROWS, THROWS]], + ['only the last poll throws', [null, null, THROWS, null]], + ['a poll gets a JSON-RPC error', [null, RPC_ERROR, null, null]], + ['a poll gets an empty list', [EMPTY, null, null, null]], + ['only the final read throws', [null, null, null, THROWS]], + ['only the final read gets a JSON-RPC error', [null, null, null, RPC_ERROR]], + ['a poll saw it processed and later reads lost it', [processed, null, null, null]], + ['the final read sees it processed', [null, null, null, processed]], + ])('stays pending when %s', async (_name, answers) => { + reads(answers) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + + it('counts a status read answered with a server error as unanswered', async () => { + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + const { method } = JSON.parse(String(init.body)) as { method: string } + if (method === 'sendTransaction') { + return new Response(JSON.stringify({ result: signed.signature })) + } + if (method === 'isBlockhashValid') { + return new Response(JSON.stringify({ result: { value: false } })) + } + return new Response(JSON.stringify({ result: { value: [null] } }), { status: 503 }) + }) + await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ + ok: false, + pending: true, + }) + }) + + it('is final only when every poll and the final read answered and none saw it', async () => { + reads([null, null, null, null]) + await expect(submit()(input(signed.transaction))).resolves.toEqual({ + ok: false, + error: EXPIRED, + }) + }) + + it('makes the final read after the blockhash answer, searching history', async () => { + const { calls } = reads([null, null, null, null]) + await submit()(input(signed.transaction)) + expect(calls.at(-2)?.method).toBe('isBlockhashValid') + expect(calls.at(-1)?.method).toBe('getSignatureStatuses') + expect(calls.at(-1)?.params[0]).toEqual([signed.signature]) + expect(calls.at(-1)?.params[1]).toEqual({ searchTransactionHistory: true }) + }) + + it('reports success when the final read finds it confirmed', async () => { + reads([THROWS, null, null, confirmed]) + await expect(submit()(input(signed.transaction))).resolves.toEqual({ + ok: true, + hash: signed.signature, + explorerUrl: `https://solscan.io/tx/${signed.signature}`, + }) + }) + + it('does not make a final read while the blockhash is still valid', async () => { + const { calls } = reads([null, null, null], true) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(3) + }) + }) + + describe('a transaction that failed in a block', () => { + const failed = { InstructionError: [0, 'x'] } + + it('does not believe a processed failure, which can sit on a dropped fork', async () => { + stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + if (method === 'isBlockhashValid') return ok({ context: { slot: 1 }, value: true }) + return status({ confirmationStatus: 'processed', err: failed }) + }) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).not.toContain('did not move') + }) + + it('stays pending when a processed failure is all there is at expiry', async () => { + stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + if (method === 'isBlockhashValid') return ok({ context: { slot: 1 }, value: false }) + return status({ confirmationStatus: 'processed', err: failed }) + }) + await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ + ok: false, + pending: true, + }) + }) + + it('reports the success that follows a processed failure on a dropped fork', async () => { + let polls = 0 + stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + polls += 1 + return polls === 1 + ? status({ confirmationStatus: 'processed', err: failed }) + : status({ confirmationStatus: 'confirmed', err: null }) + }) + await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ ok: true }) + }) + + it.each(['confirmed', 'finalized'])('believes a failure at %s', async (confirmationStatus) => { + stubRpc((method) => + method === 'sendTransaction' + ? ok(signed.signature) + : status({ confirmationStatus, err: failed }) + ) + const result = await submit()(input(signed.transaction)) + expect(result).toEqual({ + ok: false, + error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: https://solscan.io/tx/${signed.signature}`, + }) + }) + }) + + describe('what the node answered to the send', () => { + it.each([ + [ + 'an instruction failing in simulation', + { + code: -32002, + message: 'Transaction simulation failed', + data: { err: { InstructionError: [1, { Custom: 6001 }] }, logs: [] }, + }, + 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.', + ], + [ + 'BlockhashNotFound', + { + code: -32002, + message: 'Transaction simulation failed: Blockhash not found', + data: { err: 'BlockhashNotFound', logs: [] }, + }, + 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.', + ], + [ + 'a blockhash that is only named in the -32002 message', + { code: -32002, message: 'Transaction simulation failed: Blockhash not found' }, + 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.', + ], + [ + 'AccountNotFound', + { code: -32002, message: 'simulation failed', data: { err: 'AccountNotFound', logs: [] } }, + 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.', + ], + [ + 'InsufficientFundsForFee', + { + code: -32002, + message: 'simulation failed', + data: { err: 'InsufficientFundsForFee', logs: [] }, + }, + 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.', + ], + [ + 'a balance error in the simulation logs', + { + code: -32002, + message: 'Transaction simulation failed', + data: { + err: { InstructionError: [1, { Custom: 1 }] }, + logs: ['Program log: Error: insufficient funds'], + }, + }, + 'This wallet’s balance is too low for this transaction, so nothing was sent. Check your balance, including a little SOL for fees, and try again.', + ], + ])('keeps %s clean, as written', async (_name, error, wording) => { + const { calls } = stubRpc(() => ({ error })) + const result = await submit()(input(signed.transaction)) + expect(result).toEqual({ ok: false, error: wording }) + expect(calls).toHaveLength(1) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + }) + + it.each([ + ['an internal error', { code: -32603, message: 'Internal error' }], + ['a node that is behind', { code: -32005, message: 'Node is behind by 42 slots' }], + ['a gateway error with no code', { message: 'Bad gateway' }], + ['a gateway that mentions simulation', { code: -32603, message: 'Simulation timed out' }], + ['a gateway that mentions the blockhash', { code: -32000, message: 'Blockhash not found' }], + [ + 'balance words outside a preflight failure', + { code: -32603, message: 'x', data: { logs: ['insufficient funds'] } }, + ], + [ + 'a transaction that already landed', + { + code: -32002, + message: 'Transaction simulation failed: This transaction has already been processed', + data: { err: 'AlreadyProcessed', logs: [] }, + }, + ], + [ + 'an already processed message with no error name', + { + code: -32002, + message: 'Transaction simulation failed: This transaction has already been processed', + }, + ], + ])('treats %s as unknown', async (_name, error) => { + stubRpc(() => ({ error })) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringContaining(`https://solscan.io/tx/${signed.signature}`), + }) + expect(JSON.stringify(result)).not.toMatch(/nothing was sent|nothing moved/) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('does not read a refusal out of a non-2xx answer', async () => { + vi.stubGlobal( + 'fetch', + async () => + new Response( + JSON.stringify({ error: { code: -32002, message: 'Transaction simulation failed' } }), + { status: 502 } + ) + ) + await expect(submit()(input(signed.transaction))).rejects.toThrow() + }) + }) }) describe('jupiter execute', () => { @@ -270,34 +536,203 @@ describe('jupiter execute', () => { expect(urls).toHaveLength(0) }) + const EXPIRED_SWAP = + 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.' + const UNREADABLE_SWAP = + 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.' + it.each([ - [-1, /expired|fresh quote/i], - [-1005, /expired/i], - [-1004, /expired/i], - [-1000, /did not land/i], - [-1003, /sign/i], - [-9999, /did not go through/i], - ])('explains execute code %i', async (code, message) => { + [-1, EXPIRED_SWAP], + [-1004, EXPIRED_SWAP], + [-1005, EXPIRED_SWAP], + [-2003, EXPIRED_SWAP], + [-2, UNREADABLE_SWAP], + [-3, UNREADABLE_SWAP], + [-1002, UNREADABLE_SWAP], + [-1003, UNREADABLE_SWAP], + [-2002, UNREADABLE_SWAP], + ])('keeps execute code %i clean, as written', async (code, wording) => { answering({ status: 'Failed', code, error: 'upstream text' }) const result = await venue.submit?.(executeInput) - expect(result).toMatchObject({ ok: false, error: expect.stringMatching(message) }) - expect(JSON.stringify(result)).not.toContain('upstream text') + expect(result).toEqual({ ok: false, error: wording }) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'failed', latched: false }) }) - it('reports a rejected request as nothing sent', async () => { - answering({ error: 'bad' }, 400) - await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ + it.each([ + ['-1000, which only says it failed to land', { status: 'Failed', code: -1000 }], + ['-2000, which only says it failed to land', { status: 'Failed', code: -2000 }], + ['an unknown code', { status: 'Failed', code: -9999, error: 'upstream text' }], + ['a failure with no code', { status: 'Failed' }], + ['Success with no signature', { status: 'Success', code: 0 }], + ['Success with a clean code and no signature', { status: 'Success', code: -1 }], + ['a status it has never used', { status: 'Processing', code: -1 }], + ['a clean code with no status on a 200', { code: -1 }], + ['an unreadable body on a 200', 'not an execute reply'], + ])('treats %s as unknown', async (_name, body) => { + answering(body) + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ ok: false, - error: expect.stringMatching(/fresh quote/i), + pending: true, + error: expect.stringMatching(/don.t send it again/i), }) + expect(result).toMatchObject({ error: expect.stringMatching(/wallet.s activity/i) }) + expect(JSON.stringify(result)).not.toMatch( + /did not go through|did not land|nothing moved|nothing was sent|upstream text/ + ) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) }) - it('leaves an unknown outcome to the route, which tells the user to check their wallet', async () => { + it.each([ + ['-1000', { status: 'Failed', code: -1000, signature: 'jupiter-signature' }, 200], + ['an unknown code', { status: 'Failed', code: -9999, signature: 'jupiter-signature' }, 200], + ['a non-2xx answer', { status: 'Success', signature: 'jupiter-signature' }, 400], + ])('points at Jupiter’s signature for %s', async (_name, body, statusCode) => { + answering(body, statusCode) + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringContaining('https://solscan.io/tx/jupiter-signature'), + }) + expect(result).toMatchObject({ error: expect.stringMatching(/don.t send it again/i) }) + }) + + it.each([400, 401, 403, 404, 408, 409, 422])( + 'treats a %i with no known code as unknown', + async (statusCode) => { + answering({ error: 'bad' }, statusCode) + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringMatching(/don.t send it again/i), + }) + expect(JSON.stringify(result)).not.toContain('nothing was sent') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + } + ) + + it('treats a non-2xx with an unreadable body as unknown', async () => { + vi.stubGlobal('fetch', async () => new Response('Bad request', { status: 400 })) + await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it.each([ + ['-1000', { status: 'Failed', code: -1000 }], + ['an unknown code', { code: -9999 }], + ['a clean code beside a Success status', { status: 'Success', code: -1 }], + ])('treats a 400 carrying %s as unknown', async (_name, body) => { + answering(body, 400) + await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it.each([ + ['with a Failed status', { status: 'Failed', code: -1005 }, EXPIRED_SWAP], + ['with no status', { code: -2 }, UNREADABLE_SWAP], + ])('believes a known clean code on a 400 %s', async (_name, body, wording) => { + answering(body, 400) + await expect(venue.submit?.(executeInput)).resolves.toEqual({ ok: false, error: wording }) + }) + + it('leaves a server failure to the route, which tells the user to check their wallet', async () => { answering({}, 500) await expect(venue.submit?.(executeInput)).rejects.toThrow() + }) + + it('answers for itself when execute times out, naming the swap to check', async () => { vi.stubGlobal('fetch', async () => { throw new DOMException('timed out', 'TimeoutError') }) - await expect(venue.submit?.(executeInput)).rejects.toThrow() + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).toContain(`https://solscan.io/tx/${swapSigned.signature}`) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('gives up on execute inside the route’s 60 seconds', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + answering({ status: 'Success', signature: swapSigned.signature }) + await venue.submit?.(executeInput) + expect(timeout.mock.calls.map(([ms]) => ms).every((ms) => ms <= 50_000)).toBe(true) + expect(timeout).toHaveBeenCalled() + timeout.mockRestore() + }) +}) + +describe('the chain route\u2019s deadline on a Solana submit', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + const fakeTime = (): number => { + const startedAt = Date.now() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: startedAt }) + return startedAt + } + + async function settle(pending: Promise): Promise { + let done = false + void pending.finally(() => { + done = true + }) + for (let i = 0; i < 400 && !done; i += 1) await vi.advanceTimersByTimeAsync(1_000) + return pending + } + + it('answers pending within the route’s limit when the status never comes', async () => { + const { calls } = stubRpc((method) => + method === 'sendTransaction' ? ok(signed.signature) : status(null) + ) + const startedAt = fakeTime() + const result = await settle(solanaModule.submit(input(signed.transaction))) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(Date.now() - startedAt).toBeLessThan(55_000) + expect(calls.map((c) => c.method)).not.toContain('isBlockhashValid') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('stays inside the limit when every read is slow, and shrinks what each may take', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + const { method } = JSON.parse(String(init.body)) as { method: string } + if (method === 'sendTransaction') { + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: signed.signature })) + } + await new Promise((resolve) => setTimeout(resolve, 14_000)) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, ...status(null) })) + }) + const startedAt = fakeTime() + const result = await settle(solanaModule.submit(input(signed.transaction))) + expect(result).toMatchObject({ ok: false, pending: true }) + // This stub ignores the abort, so the time taken here overstates what a real read would. + expect(Date.now() - startedAt).toBeLessThan(50_000 + 2_000 + 14_000) + const last = timeout.mock.calls.at(-1)?.[0] as number + expect(last).toBeLessThan(2_000) + }) + + it('still reports a confirmation inside the limit', async () => { + stubRpc((method) => + method === 'sendTransaction' + ? ok(signed.signature) + : status({ confirmationStatus: 'confirmed', err: null }) + ) + fakeTime() + await expect(settle(solanaModule.submit(input(signed.transaction)))).resolves.toMatchObject({ + ok: true, + }) + }) + + it('leaves a submit without a deadline polling as long as before', async () => { + const { calls } = stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + return method === 'isBlockhashValid' ? ok({ value: true }) : status(null) + }) + const startedAt = fakeTime() + const result = await settle(createSolanaSubmit({ env: {} })(input(signed.transaction))) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(60_000) + expect(calls.map((c) => c.method)).toContain('isBlockhashValid') }) }) diff --git a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts new file mode 100644 index 00000000..abd44e4f --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts @@ -0,0 +1,672 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AgentProposalResult } from '../agents/brain' +import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes' +import { planScore } from '../agents/plan-scoring' +import { applyChainMeasure, isUnmeasuredSwap } from '../agents/settle-chain-plan' +import { clearTokenCache } from '../chains/near/oneclick' +import { oneClickOriginVenue } from '../chains/oneclick-origin' +import { + CAPTURE_ACCOUNT, + JUPITER_SOL_TO_USDC, +} from '../chains/solana/__fixtures__/captured-transactions' +import { createJupiterLendVenue } from '../chains/solana/jupiter-lend' +import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap' +import { createKaminoVenue } from '../chains/solana/kamino' +import { createLuloVenue } from '../chains/solana/lulo' +import { buildSolanaVenues, solanaModule } from '../chains/solana/module' +import { solanaVenues } from '../chains/solana/venues' +import { cetusVenue } from '../chains/sui/cetus' +import { buildSuiVenues, suiModule } from '../chains/sui/module' +import { naviVenue } from '../chains/sui/navi' +import { springsuiVenue } from '../chains/sui/springsui' +import { suiVenues } from '../chains/sui/venues' +import { ChainActionRefused, type ChainModule } from '../chains/types' +import { capabilitySheet } from '../orchestrate/capabilities' + +const sui = vi.hoisted(() => ({ + findRouters: vi.fn(), + fastRouterSwap: vi.fn(), + getPools: vi.fn(), + depositCoinPTB: vi.fn(), + simulateTransaction: vi.fn(), + buildUnsigned: vi.fn(), +})) + +vi.mock('@cetusprotocol/aggregator-sdk', () => ({ + AggregatorClient: class { + findRouters = sui.findRouters + fastRouterSwap = sui.fastRouterSwap + }, +})) + +vi.mock('@naviprotocol/lending', () => ({ + getPools: sui.getPools, + depositCoinPTB: sui.depositCoinPTB, +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ core: { simulateTransaction: sui.simulateTransaction } }), + buildUnsigned: sui.buildUnsigned, +})) + +vi.mock('../chains/sui/price', () => ({ + priceFromLlama: async (token: { symbol: string }) => (token.symbol === 'SUI' ? 2 : 1), +})) + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) + +const SOL = 'So11111111111111111111111111111111111111112' +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +const SUI_ME = `0x${'a1'.padStart(64, '0')}` +const USDC_SUI = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' + +type Route = (url: string, init?: RequestInit) => { status?: number; body: unknown } | undefined + +function stubFetch(route: Route): { calls: { url: string; init?: RequestInit }[] } { + const calls: { url: string; init?: RequestInit }[] = [] + vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => { + const url = String(input) + calls.push({ url, ...(init !== undefined ? { init } : {}) }) + const answer = route(url, init) + if (answer === undefined) throw new Error(`unexpected fetch ${url}`) + return new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 }) + }) + return { calls } +} + +beforeEach(() => { + clearTokenCache() + for (const mock of Object.values(sui)) mock.mockReset() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('Jupiter quote', () => { + const order = { + inputMint: SOL, + outputMint: USDC, + inAmount: '10000000', + outAmount: '931580', + otherAmountThreshold: '922264', + slippageBps: 100, + priceImpact: -0.0097, + router: 'metis', + gasless: false, + signatureFeePayer: CAPTURE_ACCOUNT, + requestId: 'request-1', + taker: CAPTURE_ACCOUNT, + transaction: JUPITER_SOL_TO_USDC, + } + const request = { kind: 'swap', amount: '0.01', tokenIn: 'SOL', tokenOut: 'USDC' } as const + const venue = () => + createJupiterSwapVenue({ + priceUsd: async (t) => (t.symbol === 'SOL' ? 93 : 1), + env: {}, + retryDelayMs: 0, + }) + + it('asks /swap/v2/order as the account and returns what comes back, not the transaction', async () => { + const { calls } = stubFetch(() => ({ body: order })) + const quoted = await venue().quote?.(request, CAPTURE_ACCOUNT) + expect(quoted).toEqual({ receive: { amount: '0.93158', symbol: 'USDC' } }) + expect(quoted).not.toHaveProperty('transaction') + expect(JSON.stringify(quoted)).not.toContain(JUPITER_SOL_TO_USDC) + const url = new URL(calls[0]?.url ?? '') + expect(url.origin + url.pathname).toBe('https://api.jup.ag/swap/v2/order') + expect(url.searchParams.get('taker')).toBe(CAPTURE_ACCOUNT) + expect(calls).toHaveLength(1) + }) + + it('refuses what prepare would refuse, so the race never offers it', async () => { + stubFetch(() => ({ body: { ...order, priceImpact: -3.5 } })) + await expect(venue().quote?.(request, CAPTURE_ACCOUNT)).rejects.toThrow(/price impact/i) + stubFetch(() => ({ body: { ...order, router: 'unknown' } })) + await expect(venue().quote?.(request, CAPTURE_ACCOUNT)).rejects.toBeInstanceOf( + ChainActionRefused + ) + }) +}) + +describe('Solana supply quotes', () => { + const supply = { kind: 'supply', amount: '10', tokenIn: 'USDC' } as const + + it('gives Jupiter Lend’s rate from its own reader, without building a deposit', async () => { + const { calls } = stubFetch(() => ({ + body: [{ assetAddress: USDC, supplyRate: '504', rewardsRate: '100' }], + })) + const quoted = await createJupiterLendVenue({ env: {} }).quote?.(supply, CAPTURE_ACCOUNT) + expect(quoted).toEqual({ apyPercent: 6.04 }) + expect(calls.every((c) => !c.url.includes('/earn/deposit'))).toBe(true) + }) + + it('leaves the rate out when it cannot be read, rather than inventing one', async () => { + stubFetch(() => ({ status: 500, body: {} })) + await expect( + createJupiterLendVenue({ env: {} }).quote?.(supply, CAPTURE_ACCOUNT) + ).resolves.toEqual({}) + }) + + it('refuses a token Jupiter Lend would refuse', async () => { + await expect( + createJupiterLendVenue({ env: {} }).quote?.({ ...supply, tokenIn: 'SOL' }, CAPTURE_ACCOUNT) + ).rejects.toThrow(/USDC or USDT/) + }) + + const vault = { + address: 'vault-1', + state: { name: 'Prime', tokenMint: USDC, prevAum: '1000', minDepositAmount: '1000000' }, + } + + it('gives Kamino’s rate for the vault it would use', async () => { + const { calls } = stubFetch((url) => + url.endsWith('/kvaults/vaults') + ? { body: [vault] } + : { body: { apy: '0.05', apyFarmRewards: '0.01' } } + ) + const quoted = await createKaminoVenue({ cacheMs: 0 }).quote?.(supply, CAPTURE_ACCOUNT) + expect(quoted?.apyPercent).toBeCloseTo(6, 6) + expect(calls.every((c) => !c.url.includes('/ktx/'))).toBe(true) + }) + + it('refuses an amount under Kamino’s vault minimum, as prepare would', async () => { + stubFetch(() => ({ body: [vault] })) + await expect( + createKaminoVenue({ cacheMs: 0 }).quote?.({ ...supply, amount: '0.5' }, CAPTURE_ACCOUNT) + ).rejects.toThrow(/at least/) + }) + + it('leaves Kamino’s rate out when its metrics cannot be read', async () => { + stubFetch((url) => + url.endsWith('/kvaults/vaults') ? { body: [vault] } : { status: 500, body: {} } + ) + await expect( + createKaminoVenue({ cacheMs: 0 }).quote?.(supply, CAPTURE_ACCOUNT) + ).resolves.toEqual({}) + }) + + it('gives Lulo’s rate, without building a deposit', async () => { + const { calls } = stubFetch(() => ({ body: { protected: { CURRENT: 7.5 } } })) + await expect( + createLuloVenue({ apiKey: 'k' }).quote?.(supply, CAPTURE_ACCOUNT) + ).resolves.toEqual({ apyPercent: 7.5 }) + expect(calls.every((c) => !c.url.includes('deposit'))).toBe(true) + }) + + it('refuses a token Lulo would refuse', async () => { + await expect( + createLuloVenue({ apiKey: 'k' }).quote?.({ ...supply, tokenIn: 'USDT' }, CAPTURE_ACCOUNT) + ).rejects.toThrow(/USDC deposits only/) + }) +}) + +describe('Cetus quote', () => { + const request = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' } as const + const route = { + amountIn: { toString: () => '10000000' }, + amountOut: { toString: () => '4950000000' }, + deviationRatio: '0.001', + paths: [{}], + } + + it('returns its best route without building a transaction', async () => { + sui.findRouters.mockResolvedValue(route) + await expect(cetusVenue.quote?.(request, SUI_ME)).resolves.toEqual({ + receive: { amount: '4.95', symbol: 'SUI' }, + }) + expect(sui.fastRouterSwap).not.toHaveBeenCalled() + expect(sui.buildUnsigned).not.toHaveBeenCalled() + }) + + it('refuses a route that was not found, or that prepare would refuse', async () => { + sui.findRouters.mockResolvedValue(null) + await expect(cetusVenue.quote?.(request, SUI_ME)).rejects.toThrow(/couldn't find a route/) + sui.findRouters.mockResolvedValue({ ...route, deviationRatio: '0.2' }) + await expect(cetusVenue.quote?.(request, SUI_ME)).rejects.toThrow(/move the price/) + }) +}) + +describe('NAVI quote', () => { + const supply = { kind: 'supply', amount: '10', tokenIn: 'USDC' } as const + const pool = { + suiCoinType: USDC_SUI, + isDeprecated: false, + leftSupply: '1000', + totalSupplyAmount: '1', + borrowedAmount: '0', + supplyIncentiveApyInfo: { vaultApr: '8.156', boostedApr: '0.344' }, + } + + it('gives the pool’s rate from NAVI’s own fields, without building a deposit', async () => { + sui.getPools.mockResolvedValue([pool]) + const quoted = await naviVenue.quote?.(supply, SUI_ME) + expect(quoted?.apyPercent).toBeCloseTo(8.5, 6) + expect(sui.depositCoinPTB).not.toHaveBeenCalled() + expect(sui.buildUnsigned).not.toHaveBeenCalled() + }) + + it('leaves the rate out when NAVI gives none', async () => { + sui.getPools.mockResolvedValue([{ ...pool, supplyIncentiveApyInfo: {} }]) + await expect(naviVenue.quote?.(supply, SUI_ME)).resolves.toEqual({}) + }) + + it('refuses a closed pool, a full pool and another token', async () => { + sui.getPools.mockResolvedValue([{ ...pool, isDeprecated: true }]) + await expect(naviVenue.quote?.(supply, SUI_ME)).rejects.toThrow(/isn't open/) + sui.getPools.mockResolvedValue([{ ...pool, leftSupply: '5' }]) + await expect(naviVenue.quote?.(supply, SUI_ME)).rejects.toThrow(/only has room/) + await expect(naviVenue.quote?.({ ...supply, tokenIn: 'SUI' }, SUI_ME)).rejects.toThrow( + /takes USDC/ + ) + }) +}) + +describe('SpringSui quote', () => { + const stake = { kind: 'stake', amount: '1', tokenIn: 'SUI' } as const + + it('says what the stake mints, and carries no rate because none can be read', async () => { + sui.buildUnsigned.mockResolvedValue('AAAA') + const { SSUI_TYPE } = await import('../chains/sui/policies') + sui.simulateTransaction.mockResolvedValue({ + Transaction: { + balanceChanges: [{ coinType: SSUI_TYPE, address: SUI_ME, amount: '963360812' }], + }, + }) + const quoted = await springsuiVenue.quote?.(stake, SUI_ME) + expect(quoted).toEqual({ receive: { amount: '0.96336', symbol: 'sSUI' } }) + expect(quoted).not.toHaveProperty('apyPercent') + expect(JSON.stringify(quoted)).not.toContain('AAAA') + }) + + it('refuses another token', async () => { + await expect(springsuiVenue.quote?.({ ...stake, tokenIn: 'USDC' }, SUI_ME)).rejects.toThrow( + /stakes SUI/ + ) + }) +}) + +describe('the race on these chains', () => { + const priceUpdatedAt = (): string => new Date().toISOString() + const list = () => [ + { + assetId: 'nep141:sol-usdc', + blockchain: 'sol', + symbol: 'USDC', + decimals: 6, + price: 1, + priceUpdatedAt: priceUpdatedAt(), + contractAddress: USDC, + }, + { + assetId: 'nep141:sol', + blockchain: 'sol', + symbol: 'SOL', + decimals: 9, + price: 100, + priceUpdatedAt: priceUpdatedAt(), + }, + ] + + function chainApi(): ChainModule { + return { + ...solanaModule, + priceUsd: async () => 1, + venues: [ + createJupiterSwapVenue({ + priceUsd: async (t) => (t.symbol === 'SOL' ? 100 : 1), + env: {}, + retryDelayMs: 0, + }), + oneClickOriginVenue('solana'), + ], + } + } + + function stubBoth(order: Record): Record[] { + const quotes: Record[] = [] + stubFetch((url, init) => { + if (url.includes('/swap/v2/order')) return { body: order } + if (url.endsWith('/v0/tokens')) return { body: list() } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Record + quotes.push(body) + return { + status: 201, + body: { + quote: { + amountIn: body['amount'], + amountInFormatted: '1', + amountInUsd: '1', + minAmountIn: body['amount'], + amountOut: '10000000', + amountOutFormatted: '0.01', + amountOutUsd: '0.99', + minAmountOut: '9900000', + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + }, + } + } + return undefined + }) + return quotes + } + + const baseOrder = { + slippageBps: 100, + priceImpact: -0.01, + router: 'metis', + gasless: false, + signatureFeePayer: CAPTURE_ACCOUNT, + requestId: 'r', + taker: CAPTURE_ACCOUNT, + transaction: JUPITER_SOL_TO_USDC, + } + + it('races Jupiter against 1Click on a dry quote for USDC to SOL', async () => { + const quotes = stubBoth({ + ...baseOrder, + inputMint: USDC, + outputMint: SOL, + inAmount: '1000000', + outAmount: '10000000', + otherAmountThreshold: '9900000', + }) + const routes = await quoteChainRoutes( + chainApi(), + { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SOL' }, + CAPTURE_ACCOUNT + ) + expect(routes.map((r) => r.id)).toEqual(['jupiter-1', '1click-solana-1']) + expect(quotes.map((q) => q['dry'])).toEqual([true]) + }) + + it('leaves SOL input to Jupiter alone, with 1Click dropped from the race', async () => { + const quotes = stubBoth({ + ...baseOrder, + inputMint: SOL, + outputMint: USDC, + inAmount: '10000000', + outAmount: '1000000', + otherAmountThreshold: '990000', + }) + const routes = await quoteChainRoutes( + chainApi(), + { kind: 'swap', amount: '0.01', tokenIn: 'SOL', tokenOut: 'USDC' }, + CAPTURE_ACCOUNT + ) + expect(routes.map((r) => r.id)).toEqual(['jupiter-1']) + expect(quotes).toHaveLength(0) + }) + + it('wires 1Click as a swap venue on both chains, keeping the defaults', () => { + const withSecret = { AUTH_SECRET: 'x'.repeat(48) } + expect(buildSolanaVenues(withSecret).find((v) => v.id === '1click-solana')?.kinds).toEqual([ + 'swap', + ]) + expect(buildSuiVenues(withSecret).find((v) => v.id === '1click-sui')?.kinds).toEqual(['swap']) + expect(solanaModule.defaultVenue.swap).toBe('jupiter') + expect(suiModule.defaultVenue.swap).toBe('cetus') + }) + + it.each([ + ['no secret', {}], + ['one too short to trust', { AUTH_SECRET: 'short' }], + ])('wires no 1Click at all with %s, as Lulo waits for its key', (_name, env) => { + expect(buildSolanaVenues(env).map((v) => v.id)).not.toContain('1click-solana') + expect(buildSuiVenues(env).map((v) => v.id)).not.toContain('1click-sui') + }) + + it('gives every venue on these chains a quote to race with', () => { + const lulo = createLuloVenue({ apiKey: 'k' }) + const env = { AUTH_SECRET: 'x'.repeat(48) } + for (const venue of [...buildSolanaVenues(env), lulo, ...buildSuiVenues(env)]) { + expect(venue.quote, venue.id).toBeTypeOf('function') + } + }) + + it('lists 1Click on the Apps page without claiming it executes, as Lulo does', () => { + // These rows reach the browser, which can't read the secret that decides it. + for (const row of [ + solanaVenues.find((v) => v.id === '1click-solana'), + suiVenues.find((v) => v.id === '1click-sui'), + ]) { + expect(row).toBeDefined() + expect(row?.integration).toBeUndefined() + expect(row?.capability).toBeUndefined() + expect(row?.bestFor).toMatch(/USDC or USDT/) + } + }) + + it('makes no claim on Sui about which tokens 1Click lists today', () => { + const row = suiVenues.find((v) => v.id === '1click-sui') + expect(JSON.stringify(row)).not.toMatch(/the pair|lists here/) + }) + + it('says agents compete on Solana and Sui, and nothing these chains cannot do', () => { + const solana = capabilitySheet('solana') + expect(solana).toContain('Agents compete') + expect(solana).not.toMatch(/Stake:|Send:|limit order/) + const onSui = capabilitySheet('sui') + expect(onSui).toContain('Agents compete') + expect(onSui).toContain('Stake:') + expect(onSui).not.toMatch(/Send:|limit order/) + }) +}) + +describe('what the race compares', () => { + const swapToSui = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' } as const + const updated = (): string => new Date().toISOString() + const list = () => [ + { + assetId: 'nep141:sui-usdc', + blockchain: 'sui', + symbol: 'USDC', + decimals: 6, + price: 1, + priceUpdatedAt: updated(), + contractAddress: USDC_SUI, + }, + { + assetId: 'nep141:sui', + blockchain: 'sui', + symbol: 'SUI', + decimals: 9, + price: 2, + priceUpdatedAt: updated(), + }, + ] + + function suiRace(amountOut: string, ownUsd: string): void { + stubFetch((url, init) => { + if (url.endsWith('/v0/tokens')) return { body: list() } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Record + return { + status: 201, + body: { + quote: { + amountIn: body['amount'], + amountInFormatted: '10', + amountInUsd: '10', + minAmountIn: body['amount'], + amountOut, + amountOutFormatted: 'x', + amountOutUsd: ownUsd, + minAmountOut: ((BigInt(amountOut) * BigInt(995)) / BigInt(1000)).toString(), + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + }, + } + } + return undefined + }) + } + + const suiChain = (): ChainModule => ({ + ...suiModule, + priceUsd: async (t) => (t.symbol === 'SUI' ? 2 : 1), + venues: [cetusVenue, oneClickOriginVenue('sui')], + }) + + /** Each route's score, as the race ranks them once the agents have picked. */ + async function scores(): Promise> { + const routes = await quoteChainRoutes(suiChain(), swapToSui, SUI_ME) + const proposal = { + projectedAvgPriceUsd: 0, + projectedSlippagePct: 0, + executionMode: 'fill', + sliceCount: 1, + } as unknown as AgentProposalResult + return Object.fromEntries( + routes.map((r) => { + const measured = applyChainMeasure( + { ...proposal, routeId: r.id }, + r.quote as ChainRouteQuote + ) + return [ + r.id, + planScore(measured, { + escrowUsd: 10, + referencePriceUsd: 1, + askedToLend: false, + askedToOfframp: false, + }), + ] + }) + ) + } + + const cetusRoute = (amountOut: string) => ({ + amountIn: { toString: () => '10000000' }, + amountOut: { toString: () => amountOut }, + deviationRatio: '0.001', + paths: [{}], + }) + + it('lets the route delivering more win, when 1Click values its smaller payout higher', async () => { + sui.findRouters.mockResolvedValue(cetusRoute('4950000000')) + suiRace('4900000000', '9.99') + const score = await scores() + expect(score['cetus-1']).toBeGreaterThan(score['1click-sui-1'] as number) + }) + + it('lets 1Click win when it delivers more, though its own figure says otherwise', async () => { + sui.findRouters.mockResolvedValue(cetusRoute('4900000000')) + suiRace('4980000000', '9.60') + const score = await scores() + expect(score['1click-sui-1']).toBeGreaterThan(score['cetus-1'] as number) + }) + + it('still drops a 1Click quote whose own figure fails its fair-value check', async () => { + sui.findRouters.mockResolvedValue(cetusRoute('4950000000')) + suiRace('4950000000', '1') + // 1Click's figure fails its own fair-value check here, so nothing of it can reach a score. + await expect(scores()).resolves.toEqual({ 'cetus-1': expect.any(Number) }) + }) + + describe('rent on Solana', () => { + const SOLANA_LIST = () => [ + { + assetId: 'nep141:sol-usdc', + blockchain: 'sol', + symbol: 'USDC', + decimals: 6, + price: 1, + priceUpdatedAt: updated(), + contractAddress: USDC, + }, + { + assetId: 'nep141:sol-usdt', + blockchain: 'sol', + symbol: 'USDT', + decimals: 6, + price: 1, + priceUpdatedAt: updated(), + contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + }, + ] + + function stubStable(): void { + stubFetch((url, init) => { + if (url.endsWith('/v0/tokens')) return { body: SOLANA_LIST() } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Record + return { + status: 201, + body: { + quote: { + amountIn: body['amount'], + amountInFormatted: '10', + amountInUsd: '10', + minAmountIn: body['amount'], + amountOut: '10000000', + amountOutFormatted: '10', + amountOutUsd: '10', + minAmountOut: '9950000', + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + }, + } + } + return undefined + }) + } + + const toUsdt = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'USDT' } as const + const solanaChain = (priceSol: number | undefined): ChainModule => ({ + ...solanaModule, + priceUsd: async (t) => (t.symbol === 'SOL' ? priceSol : 1), + venues: [oneClickOriginVenue('solana')], + }) + + it('takes the rent off the value the race sees, at the chain’s SOL price', async () => { + stubStable() + const [route] = await quoteChainRoutes(solanaChain(100), toUsdt, CAPTURE_ACCOUNT) + const q = route?.quote as ChainRouteQuote + expect(q.valueOutUsd).toBeCloseTo(10 - 0.00203928 * 100, 6) + expect(q.receive).toEqual({ amount: '10', symbol: 'USDT' }) + expect(isUnmeasuredSwap(q)).toBe(false) + }) + + it('counts the route as unmeasured when SOL has no price', async () => { + stubStable() + const [route] = await quoteChainRoutes(solanaChain(undefined), toUsdt, CAPTURE_ACCOUNT) + const q = route?.quote as ChainRouteQuote + expect(q.valueOutUsd).toBeUndefined() + expect(isUnmeasuredSwap(q)).toBe(true) + }) + + it('counts a swap the rent would swallow as unmeasured, never as a win', async () => { + stubStable() + const [route] = await quoteChainRoutes(solanaChain(100_000), toUsdt, CAPTURE_ACCOUNT) + const q = route?.quote as ChainRouteQuote + expect(q.valueOutUsd).toBeUndefined() + expect(isUnmeasuredSwap(q)).toBe(true) + }) + + it('leaves a venue with no wallet cost as it was', async () => { + stubFetch(() => undefined) + const venue = { + ...oneClickOriginVenue('solana'), + quote: async () => ({ receive: { amount: '10', symbol: 'USDT' } }), + } + const chain = { ...solanaChain(undefined), venues: [venue] } + const [route] = await quoteChainRoutes(chain, toUsdt, CAPTURE_ACCOUNT) + expect((route?.quote as ChainRouteQuote).valueOutUsd).toBe(10) + }) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index e70d7ade..8ef5ec96 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -664,15 +664,25 @@ describe('lulo', () => { }) describe('module', () => { + it('adds 1Click once the secret that protects its deposits is there', () => { + expect(buildSolanaVenues({ AUTH_SECRET: 'x'.repeat(48) }).map((v) => v.id)).toEqual([ + 'jupiter', + '1click-solana', + 'jupiter-lend', + 'kamino', + ]) + }) + it('offers the three venues that need no key', () => { expect(buildSolanaVenues({}).map((v) => v.id)).toEqual(['jupiter', 'jupiter-lend', 'kamino']) expect(solanaModule.defaultVenue).toMatchObject({ swap: 'jupiter', supply: 'jupiter-lend' }) expect(solanaModule.chain).toBe('solana') }) - it('lets only Jupiter land its own transactions', () => { - const [swap, lend, kamino] = buildSolanaVenues({}) + it('lets only the swaps land their own transactions', () => { + const [swap, oneClick, lend, kamino] = buildSolanaVenues({ AUTH_SECRET: 'x'.repeat(48) }) expect(swap?.submit).toBeTypeOf('function') + expect(oneClick?.submit).toBeTypeOf('function') expect(lend?.submit).toBeUndefined() expect(kamino?.submit).toBeUndefined() }) @@ -732,8 +742,8 @@ describe('module', () => { describe('venues page', () => { it('lists every row of the report', () => { - expect(solanaVenues).toHaveLength(14) - expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(14) + expect(solanaVenues).toHaveLength(15) + expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(15) for (const v of solanaVenues) { expect(v.family).toBe('solana') expect(v.chains).toEqual(['Solana']) diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index 4e66d732..42d0e5f0 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -8,7 +8,7 @@ import { submitSui, type SuiExecutor } from '../chains/sui/submit' import { SUI_TOKENS } from '../chains/sui/tokens' import { suiCapabilities } from '../chains/sui/capabilities' import { suiVenues } from '../chains/sui/venues' -import { suiModule } from '../chains/sui/module' +import { buildSuiVenues, suiModule } from '../chains/sui/module' import { tokenBySymbol, ChainActionRefused } from '../chains/types' import { venues } from '../venues' @@ -283,8 +283,17 @@ describe('submitting signed bytes', () => { }) describe('the module', () => { + it('adds 1Click once the secret that protects its deposits is there', () => { + expect(buildSuiVenues({ AUTH_SECRET: 'x'.repeat(48) }).map((v) => v.id)).toEqual([ + 'cetus', + '1click-sui', + 'navi', + 'springsui', + ]) + }) + it('wires only swap, supply and stake, to Cetus, NAVI and SpringSui', () => { - expect(suiModule.venues.map((v) => [v.id, v.kinds])).toEqual([ + expect(buildSuiVenues({}).map((v) => [v.id, v.kinds])).toEqual([ ['cetus', ['swap']], ['navi', ['supply']], ['springsui', ['stake']], @@ -303,8 +312,8 @@ describe('the module', () => { describe('the venue list', () => { it('has every row of the report', () => { - expect(suiVenues).toHaveLength(14) - expect(new Set(suiVenues.map((v) => v.id)).size).toBe(14) + expect(suiVenues).toHaveLength(15) + expect(new Set(suiVenues.map((v) => v.id)).size).toBe(15) }) it('is all Sui, with a real link and an honest reason', () => { @@ -317,7 +326,9 @@ describe('the venue list', () => { }) it('claims execution only for what is wired, each with a capability', () => { - const wired = suiModule.venues.map((v) => v.id).sort() + const wired = buildSuiVenues({}) + .map((v) => v.id) + .sort() const claimed = suiVenues.filter((v) => v.integration === 'executes') expect(claimed.map((v) => v.id).sort()).toEqual(wired) for (const venue of claimed) expect(venue.capability).toBeTruthy() diff --git a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts index 0571d0c9..41eafdb9 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts @@ -220,6 +220,16 @@ describe('avalancheDeposit.assertSafe', () => { ) }) + it('refuses an order naming a token other than USDC, which is all this deposit moves', () => { + const usdt = { symbol: 'USDT', id: OTHER_TOKEN, decimals: 6 } + expectRefused(() => avalancheDeposit.assertSafe(payload(), { ...plain, token: usdt }), /USDC/) + }) + + it('takes an order naming USDC as one naming nothing', () => { + const usdc = { symbol: 'USDC', id: USDC.toLowerCase(), decimals: 6 } + expect(() => avalancheDeposit.assertSafe(payload(), { ...plain, token: usdc })).not.toThrow() + }) + it('refuses an order with a memo, which this chain cannot carry', () => { expectRefused(() => avalancheDeposit.assertSafe(payload(), { ...plain, memo: '42' }), /memo/i) }) @@ -258,6 +268,12 @@ describe('avalancheDeposit.build', () => { expect(reads.sort()).toEqual([`avax ${ACCOUNT}`, `usdc ${ACCOUNT}`]) }) + it('never builds USDC for an order that names another token', async () => { + const deposit = createAvalancheDeposit(() => fakeClient()) + const usdt = { symbol: 'USDT', id: OTHER_TOKEN, decimals: 6 } + await expect(deposit.build({ ...plain, token: usdt })).rejects.toThrow(/USDC/) + }) + async function refusal(deposit: typeof avalancheDeposit, expectation = plain): Promise { return deposit.build(expectation).then( () => { @@ -335,6 +351,13 @@ describe('avalancheDeposit.submit', () => { }) ) + it('never confirms a USDC transfer for an order that names another token', async () => { + const usdt = { symbol: 'USDT', id: OTHER_TOKEN, decimals: 6 } + await expect( + withReceipt(receipt()).submit(signed, { ...plain, token: usdt }) + ).rejects.toBeInstanceOf(ChainActionRefused) + }) + it('confirms a transfer that paid this order', async () => { await expect(withReceipt(receipt()).submit(signed, plain)).resolves.toEqual({ ok: true, @@ -509,11 +532,11 @@ describe('the EVM adapters’ signPrepared', () => { return import('../adapters/evm-adapter') } - it('exists on Avalanche alone', async () => { + it('exists on Avalanche and Monad, and not on Arc', async () => { const { arcAdapter, avalancheAdapter, monadAdapter } = await adapters() expect(avalancheAdapter.signPrepared).toBeTypeOf('function') + expect(monadAdapter.signPrepared).toBeTypeOf('function') expect(arcAdapter.signPrepared).toBeUndefined() - expect(monadAdapter.signPrepared).toBeUndefined() // The first import of the adapter pulls in the wallet libraries, which takes // longer than the default when the whole suite is running. }, 30_000) diff --git a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts index bb9274e5..cbcd80f3 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts @@ -670,6 +670,40 @@ describe('submit', () => { expect(result).toMatchObject({ ok: false, pending: true }) }) + it('reports a node error that proves nothing as pending', async () => { + stubRpc({ send: () => ({ error: { code: -32603, message: 'Internal error' } }) }) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: good }, + EXPECTATION + ) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toMatch(/send it again/i) + }) + + it('reports a failed status read before expiry as pending, not as expired', async () => { + stubRpc({ + statuses: { error: { code: -32005, message: 'Node is behind' } }, + isValid: false, + }) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: good }, + EXPECTATION + ) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toMatch(/send it again/i) + }) + + it('reports a failure seen only at processed as pending', async () => { + stubRpc({ + statuses: { result: { value: [{ confirmationStatus: 'processed', err: { x: 1 } }] } }, + }) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: good }, + EXPECTATION + ) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + it('reports an expired transaction as a plain failure', async () => { stubRpc({ statuses: { result: { value: [null] } }, isValid: false }) const result = await solanaDeposit.submit( diff --git a/apps/dapp/lib/adapters/evm-adapter.ts b/apps/dapp/lib/adapters/evm-adapter.ts index 0162dc78..86d651b9 100644 --- a/apps/dapp/lib/adapters/evm-adapter.ts +++ b/apps/dapp/lib/adapters/evm-adapter.ts @@ -13,7 +13,7 @@ import { getAccount, sendTransaction, switchChain } from 'wagmi/actions' import { avalanche, monad } from 'viem/chains' import type { ChainAdapter, ChainWallet } from '../chain-adapter' -import type { SignedOutcome, SigningPayload } from '../chains/types' +import { withArticle, type SignedOutcome, type SigningPayload } from '../chains/types' import { arcTestnet, wagmiConfig } from '../wagmi.config' import { isUserRejection } from '../wallet/connector' import { walletErrorMessage } from '../wallet/error-message' @@ -93,41 +93,55 @@ function evmAdapter( } } -async function signAvalanche(payload: SigningPayload): Promise { - if (payload.family !== 'evm') { - return { ok: false, reason: 'wallet_error', detail: 'That is not an Avalanche transaction.' } - } - if (payload.chainId !== avalanche.id) { - return { ok: false, reason: 'wallet_error', detail: 'That transaction is not for Avalanche.' } - } - const { status, chainId: walletChain } = getAccount(wagmiConfig) - if (status !== 'connected') { - return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' } - } - try { - if (walletChain !== avalanche.id) { - await switchChain(wagmiConfig, { chainId: avalanche.id }) +function signOn(name: string, chainId: 43114 | 143): NonNullable { + return async (payload: SigningPayload): Promise => { + if (payload.family !== 'evm') { + return { + ok: false, + reason: 'wallet_error', + detail: `That is not ${withArticle(name)} transaction.`, + } + } + if (payload.chainId !== chainId) { + return { ok: false, reason: 'wallet_error', detail: `That transaction is not for ${name}.` } + } + // BigInt would also read hex or an empty string, and this is real money. + if (!/^\d+$/.test(payload.value)) { + return { ok: false, reason: 'wallet_error', detail: 'That transaction’s value is not valid.' } + } + const { status, chainId: walletChain } = getAccount(wagmiConfig) + if (status !== 'connected') { + return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' } + } + try { + if (walletChain !== chainId) { + await switchChain(wagmiConfig, { chainId }) + } + const hash = await sendTransaction(wagmiConfig, { + to: payload.to as Address, + data: payload.data as Hex, + value: BigInt(payload.value), + chainId, + }) + return { ok: true, signed: { family: 'evm', hash } } + } catch (e) { + // viem's message goes on to list the whole request, so its short one is what is read. + const detail = e instanceof BaseError ? e.shortMessage : walletErrorMessage(e) + const declined = + (e instanceof BaseError && + e.walk((cause) => cause instanceof UserRejectedRequestError) !== null) || + isUserRejection(detail) + return declined + ? { ok: false, reason: 'rejected' } + : { ok: false, reason: 'wallet_error', detail } } - const hash = await sendTransaction(wagmiConfig, { - to: payload.to as Address, - data: payload.data as Hex, - value: BigInt(0), - chainId: avalanche.id, - }) - return { ok: true, signed: { family: 'evm', hash } } - } catch (e) { - // viem's message goes on to list the whole request, so its short one is what is read. - const detail = e instanceof BaseError ? e.shortMessage : walletErrorMessage(e) - const declined = - (e instanceof BaseError && - e.walk((cause) => cause instanceof UserRejectedRequestError) !== null) || - isUserRejection(detail) - return declined - ? { ok: false, reason: 'rejected' } - : { ok: false, reason: 'wallet_error', detail } } } export const arcAdapter = evmAdapter(arcDescriptor, arcTestnet.id) -export const avalancheAdapter = evmAdapter(avalancheDescriptor, avalanche.id, signAvalanche) -export const monadAdapter = evmAdapter(monadDescriptor, monad.id) +export const avalancheAdapter = evmAdapter( + avalancheDescriptor, + avalanche.id, + signOn('Avalanche', avalanche.id) +) +export const monadAdapter = evmAdapter(monadDescriptor, monad.id, signOn('Monad', monad.id)) diff --git a/apps/dapp/lib/agents/brains/openai-compatible.ts b/apps/dapp/lib/agents/brains/openai-compatible.ts index 43d26563..230795b3 100644 --- a/apps/dapp/lib/agents/brains/openai-compatible.ts +++ b/apps/dapp/lib/agents/brains/openai-compatible.ts @@ -6,10 +6,12 @@ import type { BrainProvider, ProposalOutcome, ProposalRequest, + QuotedRoute, } from '../brain' import { configuredLendingVenues } from '../../lend/venues' import { anchorOn, anchorsOn } from '../../offramp/anchors' -import { SYSTEM_PROMPT } from '../brief' +import { systemPromptFor } from '../brief' +import type { ChainRouteQuote } from '../chain-routes' import { promptSafe } from '../prompt-safe' import { SUBMIT_PROPOSAL_TOOL, validateProposal } from '../tool-schema' import type { ProviderConfig } from './providers' @@ -139,7 +141,7 @@ export function buildMessages(req: ProposalRequest): { role: string; content: st .join(', ') return [ - { role: 'system', content: SYSTEM_PROMPT }, + { role: 'system', content: systemPromptFor(req.chain) }, { role: 'system', content: [ @@ -203,8 +205,16 @@ export function buildMessages(req: ProposalRequest): { role: string; content: st // The routes above are live; this table is indicative and can be badly // stale. Saying so stops an agent splitting the difference between the // two and quoting a price neither source supports. - `Market reference (context only, NOT the number to quote): $${req.intent.referencePriceUsd} per ${req.intent.input.tokenOut}`, - 'Your projectedAvgPriceUsd must come from the route you picked, not from that reference.', + ...(req.chain === 'stellar' + ? [ + `Market reference (context only, NOT the number to quote): $${req.intent.referencePriceUsd} per ${req.intent.input.tokenOut}`, + 'Your projectedAvgPriceUsd must come from the route you picked, not from that reference.', + ] + : req.intent.referencePriceUsd > 0 + ? [ + `Market reference: $${req.intent.referencePriceUsd} per ${req.intent.input.tokenIn}, the token being paid in.`, + ] + : []), req.intent.targetPriceUsd > 0 ? `Target price: $${req.intent.targetPriceUsd}` : '', ...clarifiedLines(req.context), '', @@ -261,6 +271,35 @@ function perpLines(req: ProposalRequest): string[] { ] } +/** + * The same routes on a chain other than Stellar. + * + * A supply or a stake hands back no amount, so it is shown by its rate and a + * blank "receive" is never printed. The dollar value a swap delivers is shown + * beside the amount because tokens of different decimals are only comparable + * in dollars. Nothing here calls the market synthetic: these are real quotes. + */ +function chainRouteLines(routes: QuotedRoute[]): string[] { + const line = (r: QuotedRoute): string => { + const q = r.quote as Partial | undefined + const pay = q?.pay !== undefined ? `${q.pay.amount} ${q.pay.symbol}` : r.sendAmount + const gets = + q?.receive !== undefined + ? ` -> receive ${q.receive.amount} ${q.receive.symbol}` + + (q.valueOutUsd !== undefined ? ` (about $${q.valueOutUsd.toFixed(2)})` : '') + : '' + const rate = q?.apyPercent !== undefined ? `, ${q.apyPercent}% APY (current rate)` : '' + return ` ${r.id}: send ${pay}${gets} on ${promptSafe(r.source)}${rate}` + } + return [ + '', + 'Routes, already priced by each venue:', + ...routes.map(line), + 'Choose one by putting its id in routeId. These figures are measured, not estimates,', + 'so do not quote a better number than the route you picked actually offers.', + ] +} + /** * Presents the priced routes the agent must choose between. * @@ -284,6 +323,8 @@ function routeLines(req: ProposalRequest): string[] { ] } + if (req.chain !== 'stellar') return chainRouteLines(routes) + return [ '', 'Executable routes, already priced against live liquidity:', diff --git a/apps/dapp/lib/agents/brief.ts b/apps/dapp/lib/agents/brief.ts index 6791adc6..8554668f 100644 --- a/apps/dapp/lib/agents/brief.ts +++ b/apps/dapp/lib/agents/brief.ts @@ -62,3 +62,39 @@ thenAction is independent of executionMode. Filling now and then lending is a va export const SYSTEM_PROMPT = `${SHARED_RULES} ${STRATEGIST_BRIEF}` + +/** + * What an agent is told on a chain other than Stellar. + * + * Stellar's brief talks about resting orders, splitting and anchors, none of + * which exist on these chains, and the route notes call the market a testnet. + * Here the order is a single fill on mainnet with real funds, and the agent's + * one decision is which listed route to take. The chain action settles to a + * plain fill afterward whatever an agent says, so the brief does not offer + * choices that would be ignored. + */ +const CHAIN_BRIEF = `You are one of several autonomous execution agents judging a single user action on a stablecoin-native marketplace. + +This is mainnet and the funds are real funds. The action is a single transaction, signed once by the user. There is no second step, and nothing waits for a better price. + +Your decision is which of the listed routes to take. A route is a real quote from a venue on the chain named in the market context, and the venues listed are every venue that exists for this action. Naming one that is not listed fails your proposal outright. When only one route is listed you still decide: judge from its numbers whether it is worth taking, and say so. + +How to decide: +- Use ONLY the prices and figures given to you. Never use a price or a yield from memory; if a figure is not supplied, reason in relative terms instead. +- For a swap, compare the dollar value each route delivers for the same amount paid. The route that delivers the most is usually right, unless its figures look off against the others. +- For a deposit or a stake, compare the APY each route shows. It is the current rate, not a forecast, and it can change. +- Be concrete and quantitative. State the actual numbers you are working from. +- You are proposing an execution plan, not giving financial advice. + +Return your answer by calling the submit_proposal tool. Do not reply with prose. Fill the remaining fields like this: +- routeId: the id of the route you chose. +- projectedAvgPriceUsd: the dollar price of the token being paid in, from the market context. The server replaces it with the measured figure. +- projectedSlippagePct: 0. The server measures the real figure from the quote. +- executionMode "fill", thenAction "none", sliceCount 1, and 0 for every other price or percentage field. + +Your reasoning is shown directly to the user in a chat bubble: at most two sentences and under 300 characters, no preamble, no restating the question. Never use an em dash or an en dash; use a period or a comma. Decide quickly and commit. Do not enumerate alternatives you are not going to choose, and do not re-derive the same figure twice.` + +/** The brief for this chain. Stellar's is exactly the one it has always had. */ +export function systemPromptFor(chain: string): string { + return chain === 'stellar' ? SYSTEM_PROMPT : CHAIN_BRIEF +} diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts new file mode 100644 index 00000000..008e62e3 --- /dev/null +++ b/apps/dapp/lib/agents/chain-race.ts @@ -0,0 +1,236 @@ +import type { ChainActionRequest } from '../chains/types' +import type { CompetitionErrorCode } from './competition' +import type { ChainRouteQuote } from './chain-routes' +import { isUnmeasuredSwap } from './settle-chain-plan' + +/** + * The route as a chain route, or nothing. A Stellar quote has no `kind`, so it + * can never be mistaken for one and signed through the chain path. + */ +function asChainRoute(route: unknown): ChainRouteQuote | undefined { + if (typeof route !== 'object' || route === null) return undefined + const r = route as Partial + if (r.kind !== 'chain' || typeof r.request !== 'object' || r.request === null) return undefined + if (typeof r.venueId !== 'string' || r.venueId === '') return undefined + return r as ChainRouteQuote +} + +/** + * Whether a route belongs to a chain action. Loose on purpose: anything that + * says it is a chain route must be kept away from the Stellar swap hook, even + * if it is malformed in another way. + */ +export function isChainRoute(route: unknown): boolean { + return ( + typeof route === 'object' && route !== null && (route as { kind?: unknown }).kind === 'chain' + ) +} + +/** + * The route the Stellar swap hook may be given. A chain route has no + * `sendAmount`, and the hook would read it as a Stellar quote, throw, and + * otherwise build a signable Stellar swap beside the chain's own card. + */ +export function routeForSwap(route: unknown): unknown { + return isChainRoute(route) ? undefined : route +} + +/** + * What to prepare when the user presses Review on a route. + * + * Agents only ever choose a route; the amount and tokens are what the user + * typed. So the request that gets signed is the asked one with the venue taken + * from the route, and a route that disagrees with the ask in kind, amount or + * either token is refused rather than corrected. + */ +export function chainRequestFor( + route: unknown, + asked: ChainActionRequest +): ChainActionRequest | undefined { + const chain = asChainRoute(route) + if (chain === undefined) return undefined + const r = chain.request + if ( + r.kind !== asked.kind || + r.amount !== asked.amount || + r.tokenIn !== asked.tokenIn || + (r.tokenOut ?? '') !== (asked.tokenOut ?? '') + ) { + return undefined + } + return { ...asked, venue: chain.venueId } +} + +/** Said when only one route exists, so the race does not look like it skipped the choosing. */ +export function singleRouteNote( + routeCount: number | undefined, + chainName: string +): string | undefined { + if (routeCount !== 1) return undefined + return `One route available on ${chainName}. The agents are judging whether it's worth taking.` +} + +type ChainActionPhaseName = + | 'idle' + | 'building' + | 'review' + | 'signing' + | 'submitting' + | 'settled' + | 'pending' + | 'failed' + +/** A signature is with the wallet or the network. */ +export function chainSigning(phase: ChainActionPhaseName): boolean { + return phase === 'signing' || phase === 'submitting' +} + +/** + * Review stays closed once a signature is with the wallet, was given, or the + * outcome is unknown. After a failure that followed a signature the money may + * have moved ("check your wallet's activity"), so a second signature is never + * one press away; a new message starts a new race. + */ +export function chainReviewClosed(phase: ChainActionPhaseName, signed: boolean): boolean { + return signed || chainSigning(phase) || phase === 'settled' || phase === 'pending' +} + +/** The direct-route note is true only while nothing has been signed. */ +export function showDirectNote(direct: boolean, phase: ChainActionPhaseName): boolean { + return direct && (phase === 'building' || phase === 'review') +} + +/** The agent that is shown as selected holds only while a build or its aftermath is live. */ +export function selectionHolds(phase: ChainActionPhaseName): boolean { + return phase !== 'idle' && phase !== 'failed' +} + +export type ReviewOutcome = 'prepared' | 'refused' | 'ignored' + +/** + * Pressing Review on a chain route. + * + * A press while a build is under way does nothing, so one press makes one + * build (and one deposit address). Once a signature is with the wallet or the network + * the route can no longer change. A built card that is not yet signed may be + * replaced by choosing another venue. Choosing its own venue again, from this agent + * or another, builds nothing, so the deposit address on screen is the only one. + */ +export function reviewChainRoute(args: { + route: unknown + asked: ChainActionRequest + text: string + phase: ChainActionPhaseName + /** A signature was given for this race. */ + signed?: boolean + /** The venue of the card on screen. */ + preparedVenueId?: string | undefined + /** The race this route was proposed in, so the signed action can be credited to it. */ + raceId?: string + prepare: (request: ChainActionRequest, asked: string, raceId?: string) => void + refuse: (message: string, asked: string) => void +}): ReviewOutcome { + const { phase } = args + if (phase === 'building') return 'ignored' + if (chainReviewClosed(phase, args.signed === true)) return 'ignored' + + const request = chainRequestFor(args.route, args.asked) + if (request === undefined) { + // A good card already built is not wiped by a click that cannot be used. + if (phase === 'review') return 'ignored' + args.refuse( + isChainRoute(args.route) + ? "That route doesn't match what you asked for, so it can't be used. Nothing was signed or sent." + : "This agent didn't choose a route, so there is nothing to review from it. Nothing was signed or sent.", + args.text + ) + return 'refused' + } + if (phase === 'review' && request.venue === args.preparedVenueId) return 'ignored' + if (args.raceId === undefined) args.prepare(request, args.text) + else args.prepare(request, args.text, args.raceId) + return 'prepared' +} + +/** + * Whether a finished race left the user with nothing to review, so the direct + * route should be built instead. A refusal (unknown token, bad amount, over + * the cap) is not that: it is shown as written, because building would only + * refuse again. + */ +export function needsDirectRoute(state: { + phase: string + winner: string | null + anyAnswered: boolean + error?: { code: CompetitionErrorCode } +}): boolean { + if (state.phase !== 'decided') return false + if (state.error !== undefined) return state.error.code !== 'action_refused' + return state.winner === null && !state.anyAnswered +} + +/** + * How a chain route's figures read when the usual "$ avg, % vs oracle" would + * mislead: a swap nobody could price shows as zero, which reads as perfect, + * and a supply or stake has a rate and not a price. Undefined means the usual + * display is right, which includes every Stellar route. + */ +export function chainFigures(route: unknown): { headline: string } | undefined { + const chain = asChainRoute(route) + if (chain === undefined) return undefined + if (chain.request.kind === 'swap') { + return isUnmeasuredSwap(chain) ? { headline: 'Price unknown' } : undefined + } + const apy = chain.apyPercent + return apy !== undefined && Number.isFinite(apy) + ? { headline: `${apy.toFixed(2)}% APY` } + : { headline: 'Rate not available' } +} + +/** + * The figures for a proposal on a chain race. A proposal with no route has no + * measured price or rate, and the usual zero would read as a perfect fill. + */ +export function routeFigures( + route: unknown, + asked: ChainActionRequest | undefined +): { headline: string } | undefined { + if (route !== undefined) return chainFigures(route) + if (asked === undefined) return undefined + return { headline: asked.kind === 'swap' ? 'Price unknown' : 'Rate not available' } +} + +/** The venue a route executes on: a Stellar quote's source, or a chain route's venue. */ +export function routeSource(route: unknown): string | undefined { + const r = route as { source?: unknown; venueName?: unknown } | null | undefined + const name = r?.source ?? r?.venueName + return typeof name === 'string' ? name : undefined +} + +/** + * The line under a decided race. Only a route with a measured price has a measured fill to be + * best at. Anything else, such as a supply's rate, is named for what it is. + */ +export function recommendationLine( + name: string | undefined, + figures: { headline: string } | undefined +): string { + const why = + figures === undefined + ? 'best measured fill' + : `${figures.headline.charAt(0).toLowerCase()}${figures.headline.slice(1)}` + return `Recommended: ${name}, ${why}. You pick who executes.` +} + +/** + * The panel's error text, without the claim that nothing was signed once + * something may have been. The server words its refusals with that sentence, + * which is true when they are sent and false after a direct-route signature. + */ +export function panelErrorMessage(message: string, signed: boolean): string { + if (!signed) return message + return message + .replace(/\s*Nothing was signed or sent\.?/g, '') + .replace(/\s{2,}/g, ' ') + .trim() +} diff --git a/apps/dapp/lib/agents/chain-routes.ts b/apps/dapp/lib/agents/chain-routes.ts new file mode 100644 index 00000000..7c2153d7 --- /dev/null +++ b/apps/dapp/lib/agents/chain-routes.ts @@ -0,0 +1,131 @@ +import { namedVenue, venueAnswersTo } from '../chains/prepare' +import { tokenBySymbol, type ChainActionRequest, type ChainModule } from '../chains/types' +import type { ParsedIntent } from '../parse-intent' +import type { QuotedRoute } from './brain' + +/** What a chain route carries as its `quote`, so the winner can be prepared for signing. */ +export interface ChainRouteQuote { + kind: 'chain' + request: ChainActionRequest + venueId: string + venueName: string + pay: { amount: string; symbol: string } + receive?: { amount: string; symbol: string } + apyPercent?: number + valueInUsd?: number + valueOutUsd?: number +} + +/** + * Prices the action at every venue that can do it and has a quote to give. + * + * A venue that errors is left out rather than failing the race: the agents + * choose between the prices that exist. A venue with no `quote` is skipped + * because it has no honest figure to offer, and a made-up one would be judged + * as if it were real. + */ +export async function quoteChainRoutes( + chainApi: ChainModule, + request: ChainActionRequest, + account: string +): Promise { + const named = namedVenue(chainApi, request) + const usable = chainApi.venues.filter( + (v) => + v.kinds.includes(request.kind) && + v.quote !== undefined && + (named === undefined || venueAnswersTo(v, named)) + ) + + // Dollars per display unit, from the chain's own price source. Display + // amounts times this is the only value computed here: base units are never + // converted, because token decimals differ from chain to chain. + const priceOf = async (symbol: string): Promise => { + const token = tokenBySymbol(chainApi.tokens, symbol) + if (token === undefined) return undefined + const price = await chainApi.priceUsd(token).catch(() => undefined) + return price !== undefined && Number.isFinite(price) && price > 0 ? price : undefined + } + const priceIn = await priceOf(request.tokenIn) + const amountIn = Number(request.amount) + const valueInUsd = + priceIn !== undefined && Number.isFinite(amountIn) ? amountIn * priceIn : undefined + + const settled = await Promise.all( + usable.map(async (venue): Promise => { + try { + const quoted = await venue.quote?.(request, account) + if (quoted === undefined) return undefined + // The venue's own dollar figure when it gives one; otherwise what it + // says it will hand over, priced here. Unpriced stays unknown. + let valueOutUsd = quoted.valueOutUsd + if (valueOutUsd === undefined && quoted.receive !== undefined) { + const priceOut = await priceOf(quoted.receive.symbol) + const qty = Number(quoted.receive.amount) + if (priceOut !== undefined && Number.isFinite(qty)) valueOutUsd = qty * priceOut + } + // A cost that can't be priced leaves what the route is worth unknown, not free of it. + // So does one that swallows the swap: the agents are never shown a negative value. + if (quoted.walletCost !== undefined && valueOutUsd !== undefined) { + const priceCost = await priceOf(quoted.walletCost.symbol) + const net = + priceCost === undefined + ? undefined + : valueOutUsd - Number(quoted.walletCost.amount) * priceCost + valueOutUsd = net !== undefined && net > 0 ? net : undefined + } + const quote: ChainRouteQuote = { + kind: 'chain', + request, + venueId: venue.id, + venueName: venue.name, + pay: { amount: request.amount, symbol: request.tokenIn }, + ...(quoted.receive !== undefined ? { receive: quoted.receive } : {}), + ...(quoted.apyPercent !== undefined ? { apyPercent: quoted.apyPercent } : {}), + ...(valueInUsd !== undefined ? { valueInUsd } : {}), + ...(valueOutUsd !== undefined ? { valueOutUsd } : {}), + } + return { + id: `${venue.id}-1`, + source: venue.name, + sendAmount: request.amount, + receiveAmount: quoted.receive?.amount ?? '', + hops: 1, + executable: true, + quote, + } + } catch { + return undefined + } + }) + ) + return settled.filter((r): r is QuotedRoute => r !== undefined) +} + +/** + * The least a `ParsedIntent` needs so the brain and the race log keep their + * types. Built from the structured request, never by reading words again: the + * chain, token and amount were already settled by the chat. + */ +export function intentFromChainAction( + request: ChainActionRequest, + text: string, + priceUsd: number | undefined +): ParsedIntent { + const tokenOut = request.kind === 'swap' ? (request.tokenOut ?? request.tokenIn) : request.tokenIn + const escrowUsd = priceUsd === undefined ? 0 : Math.round(Number(request.amount) * priceUsd) + return { + outcome: text, + escrowUsd, + referencePriceUsd: priceUsd ?? 0, + targetPriceUsd: priceUsd ?? 0, + input: { + type: 'market_sell', + tokenIn: request.tokenIn, + tokenOut, + amountIn: request.amount, + minAmountOut: '0', + deadline: new Date(Date.now() + 30 * 60_000).toISOString(), + }, + } +} diff --git a/apps/dapp/lib/agents/competition.ts b/apps/dapp/lib/agents/competition.ts index 2586c9e5..949ad91a 100644 --- a/apps/dapp/lib/agents/competition.ts +++ b/apps/dapp/lib/agents/competition.ts @@ -18,7 +18,12 @@ import { agentGradient } from './identity' export type CompetitionPhase = 'idle' | 'competing' | 'decided' /** Why a competition could not run at all, as opposed to one agent failing. */ -export type CompetitionErrorCode = 'agents_offline' | 'chain_unsupported' | 'no_agent_answered' +export type CompetitionErrorCode = + | 'agents_offline' + | 'chain_unsupported' + | 'no_agent_answered' + /** The action itself was refused (unknown token, bad amount, over the cap); shown as written. */ + | 'action_refused' export interface CompetitionError { code: CompetitionErrorCode @@ -59,6 +64,11 @@ export interface AgentProposalView { * Empty or absent for a plan that did nothing the brief warns against. */ penalties?: { reason: string; points: number; detail: string }[] + /** + * What to show in place of the dollar price and oracle gap, when those would + * mislead: a chain swap that could not be priced, or a rate on a supply. + */ + figures?: { headline: string } /** Set when the agent produced no proposal: timed out, refused, or was rejected. */ failed?: BrainErrorCode } @@ -83,6 +93,8 @@ export interface CompetitionState { unanimous?: boolean /** Present when the competition could not run, in place of proposals. */ error?: CompetitionError + /** How many routes the agents are choosing between. Sent only for chains other than Stellar. */ + routeCount?: number } /** diff --git a/apps/dapp/lib/agents/events.ts b/apps/dapp/lib/agents/events.ts index dc4fd0b2..c51793fb 100644 --- a/apps/dapp/lib/agents/events.ts +++ b/apps/dapp/lib/agents/events.ts @@ -23,6 +23,8 @@ export interface CompetitionStartedFrame { */ agents: { key: AgentKey; name: string; gradient: string; model: string }[] windowSeconds: number + /** How many routes the agents choose between. Sent for chains other than Stellar only. */ + routeCount?: number } export interface CompetitionProposalFrame { @@ -92,7 +94,7 @@ export interface CompetitionWinnerFrame { */ export interface CompetitionErrorFrame { type: 'competition:error' - code: 'agents_offline' | 'chain_unsupported' | 'no_agent_answered' + code: 'agents_offline' | 'chain_unsupported' | 'no_agent_answered' | 'action_refused' message: string } diff --git a/apps/dapp/lib/agents/history.ts b/apps/dapp/lib/agents/history.ts index 187b7f6a..675b2edb 100644 --- a/apps/dapp/lib/agents/history.ts +++ b/apps/dapp/lib/agents/history.ts @@ -6,6 +6,7 @@ export interface AgentHistoryEntry { raceId: string + chain: string startedAt: string tokenIn: string tokenOut: string diff --git a/apps/dapp/lib/agents/market-context.ts b/apps/dapp/lib/agents/market-context.ts index 4c041593..d44a1bed 100644 --- a/apps/dapp/lib/agents/market-context.ts +++ b/apps/dapp/lib/agents/market-context.ts @@ -18,7 +18,7 @@ import { createSoroswapAggregatorQuoter } from '../swap/sources/soroswap-aggrega import { fetchMarketPrices, toPriceTable } from '../swap/prices' import { REFERENCE_PRICES_USD } from '../parse-intent' import { tradeableSymbols, trustSummary, verificationOf } from '../swap/asset-registry' -import { isVenueOn, venues } from '../venues' +import { isVenueOn, venueOnChain, venues } from '../venues' import type { Env } from '../lend/defindex/config' import { VAULT_KEYS } from '../lend/defindex/contracts' import { readDefindexRate, type DefindexRate } from '../lend/defindex/rate' @@ -388,34 +388,53 @@ export function buildMarketContext( // // No chain means no venues. An agent with nothing to choose from is a // visible failure; an agent choosing Uniswap on Stellar is an invisible one. - const family = isChainSlug(chain) ? CHAIN_DESCRIPTORS[chain].family : undefined + const descriptor = isChainSlug(chain) ? CHAIN_DESCRIPTORS[chain] : undefined + const family = descriptor?.family // A lending venue this deployment cannot reach is absent, not listed and // failing later. DeFindex needs a key; without one it does not exist here. const lendingOffered: string[] = configuredLendingVenues(env, network) + // Solana, Sui, NEAR, Avalanche and Monad have their own venues and tokens. The + // reference prices and the asset list below describe Stellar, and an agent + // shown them would reason about tokens that do not exist on its chain. Arc + // shares a family with the last two but has no module, so it stays as it was. + const ownChain = + family === 'solana' || + family === 'sui' || + family === 'near' || + chain === 'avalanche' || + chain === 'monad' + return { asOf: new Date().toISOString(), - prices: { ...REFERENCE_PRICES_USD }, + prices: ownChain ? {} : { ...REFERENCE_PRICES_USD }, // What the agent may actually trade, with a plain-English description of // each. Without this an agent has no way to know tokenized sovereign debt // is on the menu, and would reason only about the currencies it has seen // in prior prompts. - assets: tradeableSymbols().map((code) => ({ - code, - what: verificationOf(code)?.description ?? code, - // How well the issuer is established. An agent weighing an unfamiliar - // asset should know whether the organisation named actually confirms it - // issued the thing, and the difference is invisible from a ticker. - trust: trustSummary(code) ?? 'Verification unknown.', - })), + ...(ownChain + ? {} + : { + assets: tradeableSymbols().map((code) => ({ + code, + what: verificationOf(code)?.description ?? code, + // How well the issuer is established. An agent weighing an unfamiliar + // asset should know whether the organisation named actually confirms it + // issued the thing, and the difference is invisible from a ticker. + trust: trustSummary(code) ?? 'Verification unknown.', + })), + }), // Naming a venue that does not exist on the active chain is a plausible // failure for a model, so the list it may choose from is filtered here // rather than validated after the fact. venues: venues - .filter((v) => family !== undefined && v.family === family) + .filter((v) => descriptor !== undefined && venueOnChain(v, descriptor)) // And on this network. A Stellar venue whose mainnet contracts are not // verified is testnet-only, and an agent must not be offered it. .filter((v) => v.family !== 'stellar' || isVenueOn(v, network)) + // On a chain with its own module, only a venue the app can sign for. + // One that is merely quoted would win a race it cannot complete. + .filter((v) => !ownChain || v.integration === 'executes') // Anchors are follow-on destinations, named through `thenVenue` and // validated against the anchor registry; they are not places a trade // executes and must not be selectable as a swap venue. diff --git a/apps/dapp/lib/agents/measure.ts b/apps/dapp/lib/agents/measure.ts index a1f83f69..094bc8ff 100644 --- a/apps/dapp/lib/agents/measure.ts +++ b/apps/dapp/lib/agents/measure.ts @@ -1,4 +1,5 @@ import { fromBaseUnits } from '../swap/assets' +import type { ChainRouteQuote } from './chain-routes' /** * What a route is actually worth, measured rather than claimed. @@ -72,3 +73,27 @@ export function measureRoute( efficiency, } } + +/** + * What a chain route is worth in dollars, for ranking routes against each + * other on chains whose tokens have different decimals. + * + * The figures come from the quote's dollar values, which were priced from + * display amounts. Nothing here reads base units. A route that cannot be + * priced is undefined, never zero: an unpriced route is not a free one. + * + * A supply or a stake keeps its principal rather than trading it, so it has no + * slippage to measure; it is reported at no loss and ranks on its rate instead. + */ +export function measureChainRoute( + q: ChainRouteQuote +): { outUsd: number; vsInPct: number } | undefined { + const valueIn = q.valueInUsd + if (valueIn === undefined || !Number.isFinite(valueIn) || valueIn <= 0) return undefined + + if (q.request.kind !== 'swap') return { outUsd: valueIn, vsInPct: 0 } + + const valueOut = q.valueOutUsd + if (valueOut === undefined || !Number.isFinite(valueOut) || valueOut <= 0) return undefined + return { outUsd: valueOut, vsInPct: (1 - valueOut / valueIn) * 100 } +} diff --git a/apps/dapp/lib/agents/plan-scoring.ts b/apps/dapp/lib/agents/plan-scoring.ts index 766191c2..f293d6b4 100644 --- a/apps/dapp/lib/agents/plan-scoring.ts +++ b/apps/dapp/lib/agents/plan-scoring.ts @@ -46,6 +46,14 @@ export interface PlanContext { offrampMaxUsd?: number /** The anchor's published minimum, when an offramp was offered. */ offrampMinUsd?: number + /** + * The APY each offered route shows, by route id, on a supply or a stake race. + * Those routes have no slippage to rank on, so the rate gap does it. Absent + * on a swap race. + */ + apyByRoute?: Record + /** Swap routes the server could not price. Their value is unknown, so they cannot win on it. */ + unmeasuredRoutes?: string[] } export interface PlanPenalty { @@ -57,6 +65,9 @@ export interface PlanPenalty { | 'follow_on_not_worth_fee' | 'offramp_outside_limits' | 'slices_without_split' + | 'lower_rate' + | 'unpriced_route' + | 'unrated_route' /** Points subtracted from the route score. */ points: number /** One line a person can read, naming the figures it was judged on. */ @@ -163,6 +174,50 @@ function followOnPenalties(proposal: AgentProposalResult, context: PlanContext): return out } +/** Short of the score a route starts on, so a route with a rate always outranks one without. */ +const MAX_RATE_POINTS = 99 + +/** + * One point of score per percentage point of APY given up against the best route offered. A route + * with no rate, when another has one, can't be said to earn anything, so it cannot win on rate. + */ +function ratePenalty(proposal: AgentProposalResult, context: PlanContext): PlanPenalty | undefined { + const rates = context.apyByRoute + if (rates === undefined || proposal.routeId === undefined) return undefined + const offered = Object.values(rates) + if (offered.length === 0) return undefined + const own = rates[proposal.routeId] + if (own === undefined) { + return { + reason: 'unrated_route', + points: 100, + detail: 'This route showed no rate, so what it earns is unknown.', + } + } + const best = Math.max(...offered) + const gap = best - own + if (gap <= 0) return undefined + return { + reason: 'lower_rate', + points: Math.min(Number(gap.toFixed(2)), MAX_RATE_POINTS), + detail: `Takes ${own}% APY where ${best}% was offered.`, + } +} + +/** Enough to put a route nobody could price below every route that was measured. */ +function unpricedPenalty( + proposal: AgentProposalResult, + context: PlanContext +): PlanPenalty | undefined { + if (proposal.routeId === undefined) return undefined + if (!context.unmeasuredRoutes?.includes(proposal.routeId)) return undefined + return { + reason: 'unpriced_route', + points: 100, + detail: 'This route could not be priced, so what it delivers is unknown.', + } +} + function slicePenalty(proposal: AgentProposalResult): PlanPenalty | undefined { if (proposal.sliceCount <= 1) return undefined if (proposal.executionMode === 'split') return undefined @@ -182,6 +237,12 @@ export function planPenalties(proposal: AgentProposalResult, context: PlanContex out.push(...followOnPenalties(proposal, context)) + const rate = ratePenalty(proposal, context) + if (rate !== undefined) out.push(rate) + + const unpriced = unpricedPenalty(proposal, context) + if (unpriced !== undefined) out.push(unpriced) + const slices = slicePenalty(proposal) if (slices !== undefined) out.push(slices) diff --git a/apps/dapp/lib/agents/settle-chain-plan.ts b/apps/dapp/lib/agents/settle-chain-plan.ts new file mode 100644 index 00000000..ace36b6d --- /dev/null +++ b/apps/dapp/lib/agents/settle-chain-plan.ts @@ -0,0 +1,57 @@ +import type { AgentProposalResult } from './brain' +import type { ChainRouteQuote } from './chain-routes' +import { measureChainRoute } from './measure' + +/** + * A plan on a chain other than Stellar is one fill and nothing after it. + * + * Resting, splitting and a follow-on lend or offramp have no way to execute + * there, and the prompt does not offer them. A model that picks one anyway + * keeps its route and its reasoning; only the shape of the plan is corrected, + * so scoring never rewards or punishes a choice that could not happen. + */ +export function settleChainPlan(proposal: AgentProposalResult): AgentProposalResult { + const { + restPriceUsd: _rest, + splitPct: _split, + thenAction: _then, + thenVenue: _venue, + ...rest + } = proposal + return { ...rest, executionMode: 'fill', sliceCount: 1 } +} + +/** + * A swap the server could not price. Its value is unknown, not zero loss, so + * scoring must not let it beat a route that was measured. + */ +export function isUnmeasuredSwap(q: ChainRouteQuote): boolean { + return q.request.kind === 'swap' && measureChainRoute(q) === undefined +} + +/** + * Replaces what the agent claimed about its route with what the quote says. + * + * The panel shows `projectedAvgPriceUsd` with a dollar sign, so it holds + * dollars: paid per unit received for a swap, the price of the token paid in + * for a deposit or a stake. A swap that could not be priced shows zero for + * both figures rather than the agent's guess; scoring penalises it separately. + */ +export function applyChainMeasure( + proposal: AgentProposalResult, + q: ChainRouteQuote +): AgentProposalResult { + const m = measureChainRoute(q) + if (m === undefined) { + return q.request.kind === 'swap' + ? { ...proposal, projectedAvgPriceUsd: 0, projectedSlippagePct: 0 } + : proposal + } + const unit = Number(q.request.kind === 'swap' ? q.receive?.amount : q.pay.amount) + const avg = unit > 0 && q.valueInUsd !== undefined ? q.valueInUsd / unit : 0 + return { + ...proposal, + projectedAvgPriceUsd: Number(avg.toFixed(6)), + projectedSlippagePct: Number(m.vsInPct.toFixed(2)), + } +} diff --git a/apps/dapp/lib/api/network-fetch.ts b/apps/dapp/lib/api/network-fetch.ts index c0366c8c..36fa374b 100644 --- a/apps/dapp/lib/api/network-fetch.ts +++ b/apps/dapp/lib/api/network-fetch.ts @@ -2,7 +2,7 @@ import { NETWORK_HEADER, type StellarNetworkName } from '@intent/config' import { WALLET_HEADER } from '../wallet-header' -const RACE_HEADER = 'x-intent-race' +export const RACE_HEADER = 'x-intent-race' /** * Tells the server which Stellar network a page is on. @@ -49,7 +49,9 @@ export function networkFetch( new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) headers.set(NETWORK_HEADER, network) if (connectedWallet !== undefined) headers.set(WALLET_HEADER, connectedWallet) - if (raceInFlight !== undefined && path.endsWith('/submit')) { + // A chain submit names its own race (see submitSigned), so a race left over from earlier + // can never be put on an action that did not come from it. + if (raceInFlight !== undefined && path.endsWith('/submit') && !path.startsWith('/api/chain/')) { headers.set(RACE_HEADER, raceInFlight) } return base(input, { ...init, headers }) diff --git a/apps/dapp/lib/chains/capabilities.ts b/apps/dapp/lib/chains/capabilities.ts index 4da11184..aa6d1fa2 100644 --- a/apps/dapp/lib/chains/capabilities.ts +++ b/apps/dapp/lib/chains/capabilities.ts @@ -1,5 +1,6 @@ import type { ChainSlug } from '@intent/types' +import { avalancheCapabilities, monadCapabilities } from './evm/capabilities' import type { ChainCapability } from './explain' import { nearCapabilities } from './near/capabilities' import { solanaCapabilities } from './solana/capabilities' @@ -10,5 +11,7 @@ export function chainCapabilities(slug: ChainSlug): readonly ChainCapability[] | if (slug === 'solana') return solanaCapabilities if (slug === 'sui') return suiCapabilities if (slug === 'near') return nearCapabilities + if (slug === 'avalanche') return avalancheCapabilities + if (slug === 'monad') return monadCapabilities return undefined } diff --git a/apps/dapp/lib/chains/evm/capabilities.ts b/apps/dapp/lib/chains/evm/capabilities.ts new file mode 100644 index 00000000..1a5ef115 --- /dev/null +++ b/apps/dapp/lib/chains/evm/capabilities.ts @@ -0,0 +1,10 @@ +import type { ChainCapability } from '../explain' + +/** What actually works here, for telling someone who asked for something else. Client-safe. */ +export const avalancheCapabilities: ChainCapability[] = [ + { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'AVAX' }, +] + +export const monadCapabilities: ChainCapability[] = [ + { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'MON' }, +] diff --git a/apps/dapp/lib/chains/evm/client.ts b/apps/dapp/lib/chains/evm/client.ts new file mode 100644 index 00000000..20926501 --- /dev/null +++ b/apps/dapp/lib/chains/evm/client.ts @@ -0,0 +1,57 @@ +import { + createPublicClient, + erc20Abi, + http, + type Address, + type Hash, + type TransactionReceipt, + type Transport, +} from 'viem' + +import { EVM_CHAINS, type EvmChain } from './tokens' + +const RPC_TIMEOUT_MS = 8_000 +/** Well inside the submit route's 60 seconds, which still has 1Click to wait on afterwards. */ +const RECEIPT_TIMEOUT_MS = 20_000 + +export type EvmReceipt = Pick + +export interface EvmTransaction { + value: bigint + input: string +} + +export interface EvmClient { + tokenBalance: (token: Address, account: Address) => Promise + nativeBalance: (account: Address) => Promise + waitForReceipt: (hash: Hash) => Promise + /** A receipt doesn't carry what a transaction paid or called, so these are read apart from it. */ + transaction: (hash: Hash) => Promise +} + +/** Reads this chain only, so a hash from another chain isn't found here. */ +export function evmClient(chain: EvmChain, transport?: Transport): EvmClient { + const { chain: viemChain, rpcEnv } = EVM_CHAINS[chain] + const client = createPublicClient({ + chain: viemChain, + transport: + transport ?? + http(process.env[rpcEnv]?.trim() || undefined, { timeout: RPC_TIMEOUT_MS, retryCount: 1 }), + }) + return { + tokenBalance: (token, account) => + client.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account], + }), + nativeBalance: (address) => client.getBalance({ address }), + waitForReceipt: (hash) => + client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }), + transaction: async (hash) => { + const { value, input } = await client.getTransaction({ hash }) + return { value, input } + }, + } +} diff --git a/apps/dapp/lib/chains/evm/deposit.ts b/apps/dapp/lib/chains/evm/deposit.ts new file mode 100644 index 00000000..63faac22 --- /dev/null +++ b/apps/dapp/lib/chains/evm/deposit.ts @@ -0,0 +1,198 @@ +import { decodeFunctionData, isAddress, zeroAddress, type Address, type Hex } from 'viem' + +import type { DepositChain, DepositExpectation } from '../../weave/types' +import { formatUnits } from '../near/tokens' +import { + ChainActionRefused, + toBaseUnits, + withArticle, + type ChainToken, + type SigningPayload, +} from '../types' +import { evmClient, type EvmClient } from './client' +import { + confirmEvmTransfer, + sameAddress, + sameCalldata, + TRANSFER_ABI, + transferCalldata, +} from './submit' +import { EVM_CHAINS, EVM_NATIVE_ID, type EvmChain } from './tokens' + +export interface EvmDeposit extends Pick< + DepositChain, + 'isAccount' | 'isDepositAddress' | 'build' | 'assertSafe' | 'submit' +> { + /** + * Refuses a swap this account can't pay for, with its gas. Fails closed if it can't be read. + * Answers with any notes the card should carry about what it read. + */ + assertFunds: (account: string, token: ChainToken, amount: string) => Promise +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function unitsOf(amount: string, token: ChainToken): bigint { + try { + return BigInt(toBaseUnits(amount, token.decimals)) + } catch (e) { + return refuse(e instanceof Error ? e.message : 'That is not an amount.') + } +} + +/** + * One transfer on Avalanche or Monad: the native coin to the deposit address, or one ERC-20 + * `transfer` to it. The token always comes from the order and is never assumed, because a proof + * naming USDT must not be able to build or pass as USDC. + */ +export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmDeposit { + const { + name, + chain: viemChain, + tokens, + nativeSymbol, + gasReserve, + spendReserve, + } = EVM_CHAINS[chain] + + function isAccount(address: string): boolean { + return isAddress(address, { strict: false }) && !sameAddress(address, zeroAddress) + } + + function isDepositAddress(address: string): boolean { + return ( + isAccount(address) && + !tokens.some((token) => token.id !== EVM_NATIVE_ID && sameAddress(address, token.id)) + ) + } + + function tokenOf(expectation: DepositExpectation): ChainToken { + const token = tokens.find((t) => t.id === expectation.token?.id) + return token ?? refuse(`That token isn't one this app moves on ${name}.`) + } + + function assertOrder(expectation: DepositExpectation): ChainToken { + if (expectation.memo !== undefined) refuse(`${name} cannot carry the memo this order needs.`) + if (!isDepositAddress(expectation.depositAddress)) { + refuse('This order’s deposit address cannot take a deposit.') + } + return tokenOf(expectation) + } + + function assertSafe(payload: SigningPayload, expectation: DepositExpectation): void { + if (payload.family !== 'evm') return refuse(`That is not ${withArticle(name)} transaction.`) + const token = assertOrder(expectation) + if (payload.chainId !== viemChain.id) refuse(`That transaction is not for ${name}.`) + const units = unitsOf(expectation.amount, token) + + if (token.id === EVM_NATIVE_ID) { + if (!sameAddress(payload.to, expectation.depositAddress)) { + refuse('That transfer is not to this order’s deposit address.') + } + if (payload.value !== units.toString()) { + refuse('That transfer’s amount is not this order’s amount.') + } + if (payload.data !== '0x') refuse('A transfer of the native coin carries no data.') + return + } + + if (!sameAddress(payload.to, token.id)) { + refuse(`That transaction is not a call to the ${token.symbol} contract.`) + } + if (payload.value !== '0') refuse(`That transaction also spends ${nativeSymbol} as value.`) + // Decoded against `transfer` alone, so any other selector cannot decode at all. + let args: readonly [Address, bigint] + try { + args = decodeFunctionData({ abi: TRANSFER_ABI, data: payload.data as Hex }).args + } catch { + return refuse(`That transaction is not a ${token.symbol} transfer.`) + } + const [recipient, sent] = args + if (!sameAddress(recipient, expectation.depositAddress)) { + refuse('That transfer is not to this order’s deposit address.') + } + if (sent !== units) refuse('That transfer’s amount is not this order’s amount.') + // The decoder ignores what comes after the arguments and above an address's 20 bytes. + if (!sameCalldata(transferCalldata(recipient, sent), payload.data)) { + refuse('That transaction carries extra data, and must be exactly one transfer.') + } + } + + return { + isAccount, + isDepositAddress, + assertSafe, + + assertFunds: async (account, token, amount) => { + const units = unitsOf(amount, token) + const reserve = BigInt(gasReserve) + const kept = `${formatUnits(gasReserve, 18)} ${nativeSymbol}` + const native = token.id === EVM_NATIVE_ID + let held: bigint | undefined + let coin: bigint + try { + const reader = client() + ;[held, coin] = await Promise.all([ + native ? undefined : reader.tokenBalance(token.id as Address, account as Address), + reader.nativeBalance(account as Address), + ]) + } catch { + return refuse(`Could not read this account’s balances from ${name}.`) + } + if (native) { + if (coin < units + reserve) { + refuse( + `This account needs ${amount} ${nativeSymbol} for the swap and about ${kept} more for the network fee, and holds less than that.` + ) + } + return spendReserve !== undefined && coin - units < BigInt(spendReserve.units) + ? [spendReserve.note] + : [] + } + if (held === undefined || held < units) { + refuse(`This account holds less ${token.symbol} than this swap needs.`) + } + if (coin < reserve) { + refuse( + `This account has too little ${nativeSymbol} for the network fee. Keep about ${kept}.` + ) + } + return [] + }, + + build: async (expectation) => { + const token = assertOrder(expectation) + if (!isAccount(expectation.account)) refuse(`That is not ${withArticle(name)} account.`) + const units = unitsOf(expectation.amount, token) + const payload: SigningPayload = + token.id === EVM_NATIVE_ID + ? { + family: 'evm', + chainId: viemChain.id, + to: expectation.depositAddress, + data: '0x', + value: units.toString(), + } + : { + family: 'evm', + chainId: viemChain.id, + to: token.id, + data: transferCalldata(expectation.depositAddress, units), + value: '0', + } + assertSafe(payload, expectation) + return payload + }, + + submit: (signed, expectation) => confirmEvmTransfer(chain, client, signed, expectation), + } +} + +const shared: Partial> = {} + +/** Takes the client as a function so nothing connects until a deposit is actually read. */ +export function evmDeposit(chain: EvmChain): EvmDeposit { + return createEvmDeposit(chain, () => (shared[chain] ??= evmClient(chain))) +} diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts new file mode 100644 index 00000000..8f45510c --- /dev/null +++ b/apps/dapp/lib/chains/evm/module.ts @@ -0,0 +1,52 @@ +import { oneClickPrice } from '../near/oneclick' +import { canIssueOneClick, oneClickAssetId, oneClickOriginVenue } from '../oneclick-origin' +import type { ChainModule, ChainToken, ChainVenue } from '../types' +import { EVM_CHAINS, type EvmChain } from './tokens' + +export function buildEvmVenues( + chain: EvmChain, + env: Record = process.env +): ChainVenue[] { + return canIssueOneClick(env) ? [oneClickOriginVenue(chain)] : [] +} + +/** + * Priced from 1Click's list, as NEAR is. Every route here comes from that one venue, so one source + * can't rank two routes by a gap between sources. Any doubt is no price, which refuses the trade. + */ +async function priceFromOneClick(chain: EvmChain, token: ChainToken): Promise { + try { + const assetId = await oneClickAssetId(chain, token) + return assetId === undefined ? undefined : await oneClickPrice(assetId, token.decimals) + } catch { + return undefined + } +} + +function evmModule(chain: EvmChain, tokenHints?: Record): ChainModule { + return { + chain, + tokens: EVM_CHAINS[chain].tokens, + venues: buildEvmVenues(chain), + defaultVenue: { swap: `1click-${chain}` }, + priceUsd: (token) => priceFromOneClick(chain, token), + // Every venue here lands its own transactions, and no venue means no submit gets this far. + submit: async () => ({ + ok: false, + pending: true, + error: + "This app can't follow that from here. Check your wallet's activity before trying again.", + }), + ...(tokenHints !== undefined ? { tokenHints } : {}), + } +} + +export const avalancheModule = evmModule('avalanche') + +/** + * USDT is not matched to USDT0. A person is told instead, so nothing gets signed under a name they + * didn't type, and the one token keeps the one name on the card, in the wallet and in 1Click's list. + */ +export const monadModule = evmModule('monad', { + USDT: 'On Monad, Tether’s dollar is called USDT0, so ask for USDT0.', +}) diff --git a/apps/dapp/lib/chains/evm/submit.ts b/apps/dapp/lib/chains/evm/submit.ts new file mode 100644 index 00000000..bcf7e02d --- /dev/null +++ b/apps/dapp/lib/chains/evm/submit.ts @@ -0,0 +1,146 @@ +import { + encodeFunctionData, + erc20Abi, + parseAbi, + parseEventLogs, + type Address, + type Hash, +} from 'viem' + +import type { DepositExpectation } from '../../weave/types' +import { toBaseUnits, type SignedPayload, type SubmitResult } from '../types' +import type { EvmClient, EvmReceipt } from './client' +import { EVM_CHAINS, EVM_NATIVE_ID, evmExplorerUrl, type EvmChain } from './tokens' + +export const EVM_HASH = /^0x[0-9a-fA-F]{64}$/ + +export const TRANSFER_ABI = parseAbi([ + 'function transfer(address to, uint256 amount) returns (bool)', +]) + +export function sameAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +export function sameCalldata(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +export function transferCalldata(to: string, units: bigint): string { + return encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [to as Address, units], + }) +} + +/** Whether this receipt shows this account paying exactly this token transfer to this address. */ +export function erc20TransferPaid( + receipt: EvmReceipt, + paid: { token: string; account: string; depositAddress: string; units: bigint } +): boolean { + if (!sameAddress(receipt.from, paid.account)) return false + if (receipt.to === null || !sameAddress(receipt.to, paid.token)) return false + return parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs }).some( + (log) => + sameAddress(log.address, paid.token) && + sameAddress(log.args.from, paid.account) && + sameAddress(log.args.to, paid.depositAddress) && + log.args.value === paid.units + ) +} + +function pending(error: string): SubmitResult { + return { ok: false, pending: true, error } +} + +/** + * Asks the chain what the wallet sent. The wallet broadcast this for itself, so nothing here can + * stop it, and an answer may lack `pending` only if it proves the funds did not move. Only a + * revert does, and only of this very transfer: from this account, to that address, carrying + * exactly this calldata and value. For a token `to` is the contract, so from and to alone would + * pass the account's earlier failed call on that token while the real transfer went out. + * + * A receipt that is someone else's or for something else says nothing about the real transfer, + * so it is pending too, and it is never reported to 1Click. + */ +export async function confirmEvmTransfer( + chain: EvmChain, + client: () => EvmClient, + signed: SignedPayload, + expectation: DepositExpectation +): Promise { + const { name, spendReserve } = EVM_CHAINS[chain] + const { account, depositAddress, amount, token } = expectation + if (signed.family !== 'evm' || !EVM_HASH.test(signed.hash) || token === undefined) { + return pending( + "The wallet didn't return a transaction this app can follow. Check your wallet's activity before trying again, in case it was sent." + ) + } + const { hash } = signed + const notConfirmed = pending( + `This transfer isn't confirmed on ${name} yet, and it may still land, so don't send it again. Its transaction is ${hash}.` + ) + const notThisSwap = pending( + `Transaction ${hash} is not this swap's transfer, so it was not reported to NEAR Intents. Check your wallet's activity before trying again, in case the real one went out.` + ) + + let receipt: EvmReceipt + try { + receipt = await client().waitForReceipt(hash as Hash) + } catch { + // What a hash from another chain gives too: this chain never finds it. + return notConfirmed + } + + const native = token.id === EVM_NATIVE_ID + const expectedTo = native ? depositAddress : token.id + const fromThisAccountToThatAddress = + sameAddress(receipt.from, account) && receipt.to !== null && sameAddress(receipt.to, expectedTo) + if (!fromThisAccountToThatAddress) return notThisSwap + + // With no amount to compare against, this can't be shown to be this transfer, or not to be. + let units: bigint + try { + units = BigInt(toBaseUnits(amount, token.decimals)) + } catch { + return pending( + `Transaction ${hash} couldn't be checked against this swap's amount. Check your wallet's activity before trying again, in case it went out.` + ) + } + + if (receipt.status === 'reverted') { + // The comparison sits in here too: an RPC can answer with a shape that makes it throw, and + // that says no more about the transfer than a read that failed. + let isThisTransfer: boolean + try { + const sent = await client().transaction(hash as Hash) + isThisTransfer = + sent.value === (native ? units : BigInt(0)) && + sameCalldata(sent.input, native ? '0x' : transferCalldata(depositAddress, units)) + } catch { + return pending( + `Transaction ${hash} failed on ${name}, but it couldn't be read to tell whether it was this swap's transfer. Check your wallet's activity before trying again, in case the real one went out.` + ) + } + if (!isThisTransfer) return notThisSwap + const likelyCause = native && spendReserve !== undefined ? ` ${spendReserve.reverted}` : '' + return { + ok: false, + error: `This transfer failed on ${name}. The ${token.symbol} did not move, and only the gas was spent.${likelyCause}`, + } + } + + if (native) { + let value: bigint + try { + ;({ value } = await client().transaction(hash as Hash)) + } catch { + return notConfirmed + } + if (value !== units) return notThisSwap + } else if (!erc20TransferPaid(receipt, { token: token.id, account, depositAddress, units })) { + return notThisSwap + } + return { ok: true, hash, explorerUrl: evmExplorerUrl(chain, hash) } +} diff --git a/apps/dapp/lib/chains/evm/tokens.ts b/apps/dapp/lib/chains/evm/tokens.ts new file mode 100644 index 00000000..abc24be5 --- /dev/null +++ b/apps/dapp/lib/chains/evm/tokens.ts @@ -0,0 +1,102 @@ +import { avalanche, monad } from 'viem/chains' +import type { Chain } from 'viem' + +import type { ChainToken } from '../types' + +/** + * Avalanche and Monad, which swap only through 1Click. Each contract here is matched against + * 1Click's list by `oneClickAssetId` on chain, contract and decimals, so a wrong entry finds no + * route rather than a wrong token. + */ + +export type EvmChain = 'avalanche' | 'monad' + +/** Stands for the chain's own coin, which has no contract. Not an address, so it can't be called. */ +export const EVM_NATIVE_ID = 'native' + +export const AVALANCHE_TOKENS: readonly ChainToken[] = [ + { symbol: 'AVAX', id: EVM_NATIVE_ID, decimals: 18, name: 'Avalanche' }, + // Circle's native USDC on the C-Chain, not the bridged USDC.e. + { + symbol: 'USDC', + id: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', + decimals: 6, + name: 'USD Coin', + }, + // Tether's native USDt on the C-Chain, not the bridged USDT.e. + { + symbol: 'USDT', + id: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7', + decimals: 6, + name: 'Tether USD', + }, +] + +export const MONAD_TOKENS: readonly ChainToken[] = [ + { symbol: 'MON', id: EVM_NATIVE_ID, decimals: 18, name: 'Monad' }, + { + symbol: 'USDC', + id: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', + decimals: 6, + name: 'USD Coin', + }, + { + symbol: 'USDT0', + id: '0xe7cd86e13AC4309349F30B3435a9d337750fC82D', + decimals: 6, + name: 'USDT0', + }, +] + +export interface EvmChainConfig { + name: string + chain: Chain + tokens: readonly ChainToken[] + nativeSymbol: string + /** + * Base units of the native coin kept for gas, and asked of anyone paying a token instead. Well + * over one transfer on an ordinary day and not a quote: it only decides whether to refuse early, + * because the wallet sets the real fee. + */ + gasReserve: string + /** + * A balance the chain itself protects, where it has one. A spend that dips under it can revert + * after it is signed, but not always, so this warns and doesn't refuse. + */ + spendReserve?: { units: string; note: string; reverted: string } + rpcEnv: string + explorer: string +} + +export const EVM_CHAINS: Record = { + avalanche: { + name: 'Avalanche', + chain: avalanche, + tokens: AVALANCHE_TOKENS, + nativeSymbol: 'AVAX', + gasReserve: '5000000000000000', + rpcEnv: 'AVALANCHE_RPC_URL', + explorer: 'https://snowtrace.io', + }, + monad: { + name: 'Monad', + chain: monad, + tokens: MONAD_TOKENS, + nativeSymbol: 'MON', + gasReserve: '50000000000000000', + // https://docs.monad.xyz/developer-essentials/reserve-balance. It reverts always for an + // EIP-7702-delegated account, and for others that sent within the last 3 blocks. + spendReserve: { + units: '10000000000000000000', + note: 'This would leave under 10 MON in your wallet. Monad holds a 10 MON reserve on each account, so this swap may fail because of it, and the gas would still be spent.', + reverted: + 'The likely cause is Monad’s 10 MON reserve, which can stop a spend that leaves an account under 10 MON.', + }, + rpcEnv: 'MONAD_RPC_URL', + explorer: 'https://monadvision.com', + }, +} + +export function evmExplorerUrl(chain: EvmChain, hash: string): string { + return `${EVM_CHAINS[chain].explorer}/tx/${hash}` +} diff --git a/apps/dapp/lib/chains/evm/venues.ts b/apps/dapp/lib/chains/evm/venues.ts new file mode 100644 index 00000000..bfdd94e7 --- /dev/null +++ b/apps/dapp/lib/chains/evm/venues.ts @@ -0,0 +1,26 @@ +import type { Venue } from '@intent/types' + +const ONECLICK_DOCS = + 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api' + +// Listed, as Lulo is on Solana: whether this runs depends on a secret this client-safe file can't read. +export const evmVenues: Venue[] = [ + { + id: '1click-avalanche', + family: 'evm', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Avalanche'], + url: ONECLICK_DOCS, + bestFor: 'Swaps between AVAX, USDC and USDT, priced by competing market makers', + }, + { + id: '1click-monad', + family: 'evm', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Monad'], + url: ONECLICK_DOCS, + bestFor: 'Swaps between MON, USDC and USDT0, priced by competing market makers', + }, +] diff --git a/apps/dapp/lib/chains/near/earn.ts b/apps/dapp/lib/chains/near/earn.ts index f32fa5a4..11aa5fa6 100644 --- a/apps/dapp/lib/chains/near/earn.ts +++ b/apps/dapp/lib/chains/near/earn.ts @@ -2,12 +2,14 @@ import { ChainActionRefused, tokenBySymbol, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { assertOneClickSafe } from './safety' -import { prepareOneClickDeposit, submitOneClick } from './swap' +import { prepareOneClickDeposit, quoteOneClick, submitOneClick } from './swap' import { nearTokens, USDC_NEAR, USDT_NEAR } from './tokens' export const EARN_VENUE_ID = '1click-earn' @@ -31,11 +33,25 @@ const VAULT = { const EARN_NOTE = "Your stablecoin becomes a share in Gauntlet USDC Prime, a Morpho vault on Base, held in your NEAR Intents balance. That stacks bridge, NEAR Intents and vault risk, the principal isn't guaranteed, and 1Click publishes no rate to show here. This app can't show this position or withdraw it yet. Getting out takes a separate signed withdrawal through NEAR Intents." -async function prepareEarn(request: ChainActionRequest, account: string): Promise { +function stablecoinIn(request: ChainActionRequest): ChainToken { const tokenIn = tokenBySymbol(nearTokens, request.tokenIn) - if (request.kind !== 'supply' || (tokenIn?.id !== USDC_NEAR && tokenIn?.id !== USDT_NEAR)) { + if ( + request.kind !== 'supply' || + tokenIn === undefined || + (tokenIn.id !== USDC_NEAR && tokenIn.id !== USDT_NEAR) + ) { throw new ChainActionRefused(`${VENUE_NAME} here takes USDC or USDT.`) } + return tokenIn +} + +/** No apyPercent: 1Click publishes no rate for the vault, and a rate is never invented. */ +async function quoteEarn(request: ChainActionRequest, account: string): Promise { + return quoteOneClick(request.amount, stablecoinIn(request), VAULT, account) +} + +async function prepareEarn(request: ChainActionRequest, account: string): Promise { + const tokenIn = stablecoinIn(request) return prepareOneClickDeposit( { venueId: EARN_VENUE_ID, @@ -68,6 +84,7 @@ export const earnVenue: ChainVenue = { id: EARN_VENUE_ID, name: VENUE_NAME, kinds: ['supply'], + quote: quoteEarn, prepare: prepareEarn, assertSafe: assertEarnSafe, submit: submitOneClick, diff --git a/apps/dapp/lib/chains/near/oneclick.ts b/apps/dapp/lib/chains/near/oneclick.ts index 8e8b857c..492a657f 100644 --- a/apps/dapp/lib/chains/near/oneclick.ts +++ b/apps/dapp/lib/chains/near/oneclick.ts @@ -14,6 +14,10 @@ const MAX_PRICE_AGE_MS = 15 * 60_000 /** The combined fee limit is 500 bps, and 1Click adds 25 of its own without a key. */ const MAX_APP_FEE_BPS = 475 +/** + * A NEAR token's asset id, which is its account by rule. Other chains' ids can't be worked + * out from a contract, so they come from the list through `oneClickAssetId`. + */ export function assetIdOf(token: ChainToken): string { return `nep141:${token.id}` } @@ -42,6 +46,9 @@ const TokenList = z.array( symbol: z.string(), price: z.number().optional(), priceUpdatedAt: z.string().optional(), + // Allowed to be null so that a change to these can't stop NEAR's prices from being read. + blockchain: z.string().nullish(), + contractAddress: z.string().nullish(), }) ) export type OneClickToken = z.infer[number] @@ -230,11 +237,14 @@ export async function requestQuote(input: QuoteInput): Promise { return parsed.data } -/** Optional speed-up, per the docs. A failure here changes nothing about the swap. */ +/** + * Optional speed-up, per the docs. A failure here changes nothing about the swap. The sender + * account only means something on NEAR, so other chains leave it out. + */ export async function notifyDeposit( depositAddress: string, txHash: string, - nearSenderAccount: string + nearSenderAccount?: string ): Promise { await fetch(`${BASE_URL}/deposit/submit`, { method: 'POST', diff --git a/apps/dapp/lib/chains/near/stake.ts b/apps/dapp/lib/chains/near/stake.ts index 009cd178..87e67e25 100644 --- a/apps/dapp/lib/chains/near/stake.ts +++ b/apps/dapp/lib/chains/near/stake.ts @@ -8,6 +8,7 @@ import { type PreparedAction, type SubmitInput, type SubmitResult, + type VenueQuote, } from '../types' import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account' import { isRegistered, viewCall, waitForTransaction } from './rpc' @@ -81,6 +82,38 @@ function stakeCall(amount: string, gas: bigint): NearTransaction['actions'][numb } } +/** + * What a stake would give back, without building the transaction. A rate Rhea does not + * give is left out rather than guessed. Meta Pool has no rate endpoint here, so its quote + * carries none. + */ +async function quoteRnear(request: ChainActionRequest, account: string): Promise { + assertStakeRequest(request, account, 'rNEAR') + const amount = toBaseUnits(request.amount, 24) + let receive: string + try { + receive = receiptAmount(amount, await viewCall(RNEAR_CONTRACT, 'ft_price')) + } catch { + throw cannotRead('rNEAR') + } + const apyPercent = await rnearApyPercent() + return { + receive: { amount: receive, symbol: 'rNEAR' }, + ...(apyPercent !== undefined ? { apyPercent } : {}), + } +} + +async function quoteMetapool(request: ChainActionRequest, account: string): Promise { + assertStakeRequest(request, account, 'Meta Pool') + const amount = toBaseUnits(request.amount, 24) + try { + const price = await viewCall(METAPOOL_CONTRACT, 'get_st_near_price') + return { receive: { amount: receiptAmount(amount, price), symbol: 'stNEAR' } } + } catch { + throw cannotRead('Meta Pool') + } +} + async function prepareRnear(request: ChainActionRequest, account: string): Promise { const venue = 'rNEAR' assertStakeRequest(request, account, venue) @@ -190,6 +223,7 @@ export const rnearVenue: ChainVenue = { id: RNEAR_VENUE_ID, name: 'rNEAR', kinds: ['stake'], + quote: quoteRnear, prepare: prepareRnear, assertSafe: assertRnearSafe, } @@ -198,6 +232,7 @@ export const metapoolVenue: ChainVenue = { id: METAPOOL_VENUE_ID, name: 'Meta Pool', kinds: ['stake'], + quote: quoteMetapool, prepare: prepareMetapool, assertSafe: assertMetapoolSafe, } @@ -218,11 +253,18 @@ export async function confirmStake(input: SubmitInput): Promise { const contract = Object.prototype.hasOwnProperty.call(STAKE_CONTRACTS, venueId) ? STAKE_CONTRACTS[venueId] : undefined - if (contract === undefined) return { ok: false, error: 'Nothing to confirm for that venue.' } + // The wallet has already sent, so nothing here proves that nothing moved. + if (contract === undefined) { + return { ok: false, pending: true, error: 'Nothing to confirm for that venue.' } + } const hash = signed.hashes[signed.hashes.length - 1] if (hash === undefined || signed.hashes.some((h) => !NEAR_TX_HASH.test(h))) { - return { ok: false, error: 'The wallet did not return a transaction hash to follow.' } + return { + ok: false, + pending: true, + error: 'The wallet did not return a transaction hash to follow.', + } } const giveUpAt = Date.now() + CONFIRM_WAIT_MS @@ -231,6 +273,7 @@ export async function confirmStake(input: SubmitInput): Promise { if (outcome.state === 'unknown') { return { ok: false, + pending: true, error: `Your transaction was sent (${each}) but hasn't been confirmed yet. Don't send it again. Check ${explorerUrl(each)} in a minute.`, } } @@ -244,6 +287,7 @@ export async function confirmStake(input: SubmitInput): Promise { if (outcome.signer !== account || outcome.receiver !== contract || !stakes) { return { ok: false, + pending: true, error: `Transaction ${each} is not this account's stake with this venue. Check your wallet's activity.`, } } diff --git a/apps/dapp/lib/chains/near/swap.ts b/apps/dapp/lib/chains/near/swap.ts index 7fc45a9f..6802f5a8 100644 --- a/apps/dapp/lib/chains/near/swap.ts +++ b/apps/dapp/lib/chains/near/swap.ts @@ -9,6 +9,7 @@ import { type PreparedAction, type SubmitInput, type SubmitResult, + type VenueQuote, } from '../types' import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account' import { @@ -44,15 +45,28 @@ const CONFIRM_WAIT_MS = 10_000 const STATUS_WAIT_MS = 23_000 const STATUS_POLL_MS = 2_000 -const EXPLORER_NOTE = 'You can follow it at explorer.near-intents.org by searching for' +export const EXPLORER_NOTE = 'You can follow it at explorer.near-intents.org by searching for' -interface Destination { +export const ONECLICK_RISK_NOTE = + 'NEAR Intents had an exploit of about $3.8M around 1 October 2026 in its bridge links. Funds were reported returned, but bridges and solvers it relies on remain a risk.' + +export interface Destination { assetId: string symbol: string decimals: number recipientType: 'DESTINATION_CHAIN' | 'INTENTS' } +/** What a swap spends, as 1Click names it. Off NEAR the id comes from `oneClickAssetId`. */ +export interface QuoteOrigin { + assetId: string + decimals: number +} + +function nearOrigin(token: ChainToken): QuoteOrigin { + return { assetId: assetIdOf(token), decimals: token.decimals } +} + function lossBetween(paid: number, received: number): number { return paid > 0 && Number.isFinite(paid) && Number.isFinite(received) ? 1 - received / paid @@ -67,12 +81,12 @@ function lossBetween(paid: number, received: number): number { */ async function assertFairValue( envelope: QuoteEnvelope, - tokenIn: ChainToken, + origin: QuoteOrigin, destination: Destination ): Promise { const { quote } = envelope const [priceIn, priceOut] = await Promise.all([ - oneClickPrice(assetIdOf(tokenIn), tokenIn.decimals), + oneClickPrice(origin.assetId, origin.decimals), oneClickPrice(destination.assetId, destination.decimals), ]) const quoted = lossBetween(Number(quote.amountInUsd), Number(quote.amountOutUsd)) @@ -80,7 +94,7 @@ async function assertFairValue( priceIn === undefined || priceOut === undefined ? Number.NaN : lossBetween( - toDisplayNumber(quote.amountIn, tokenIn.decimals) * priceIn, + toDisplayNumber(quote.amountIn, origin.decimals) * priceIn, toDisplayNumber(quote.amountOut, destination.decimals) * priceOut ) if (!Number.isFinite(quoted) || !Number.isFinite(listed)) { @@ -198,7 +212,24 @@ function isPositive(amount: string | undefined): amount is string { return amount !== undefined && BigInt(amount) > BigInt(0) } -function costNote(envelope: QuoteEnvelope, loss: number, symbol: string, decimals: number): string { +/** The parts of the cost note that depend on what the chain's wallet pays for itself. */ +export interface CostWording { + withdrawFor: string + total: string +} + +const NEAR_COST_WORDING: CostWording = { + withdrawFor: 'to register you on that token', + total: 'After all costs', +} + +export function costNote( + envelope: QuoteEnvelope, + loss: number, + symbol: string, + decimals: number, + wording: CostWording = NEAR_COST_WORDING +): string { const bps = feeBps(envelope) const fee = bps === undefined @@ -206,10 +237,18 @@ function costNote(envelope: QuoteEnvelope, loss: number, symbol: string, decimal : `The quote includes a ${(bps / 100).toFixed(2)}% fee, taken from what you send.` const withdraw = envelope.quote.withdrawFee const registration = isPositive(withdraw) - ? ` It also takes ${formatUnits(withdraw, decimals)} ${symbol} to register you on that token.` + ? ` It also takes ${formatUnits(withdraw, decimals)} ${symbol} ${wording.withdrawFor}.` : '' const percent = (Math.max(loss, 0) * 100).toFixed(2) - return `NEAR Intents runs on mainnet only, so this moves real funds. ${fee}${registration} After all costs you get about ${percent}% less in value than you pay, and at least ${formatUnits(envelope.quote.minAmountOut, decimals)} ${symbol}.` + return `NEAR Intents runs on mainnet only, so this moves real funds. ${fee}${registration} ${wording.total} you get about ${percent}% less in value than you pay, and at least ${formatUnits(envelope.quote.minAmountOut, decimals)} ${symbol}.` +} + +export function depositNote(envelope: QuoteEnvelope, tokenIn: ChainToken, account: string): string { + const refundFee = envelope.quote.refundFee + const refundNote = isPositive(refundFee) + ? `, minus a fee of ${formatUnits(refundFee, tokenIn.decimals)} ${tokenIn.symbol}` + : '' + return `Your funds go to a one-time deposit address from 1Click, which this app can't check on-chain. Sign within 5 minutes, because a late deposit may be refunded instead of swapped. If the swap can't finish, 1Click returns your funds to ${account}${refundNote}.` } interface DepositFlow { @@ -220,20 +259,27 @@ interface DepositFlow { extraNotes: string[] } -/** The quote flow behind every 1Click venue. Only the destination and the wording differ. */ -export async function prepareOneClickDeposit( - flow: DepositFlow, - amountText: string, - tokenIn: ChainToken, - destination: Destination, - account: string -): Promise { +function assertNearAccount(account: string): void { if (!NEAR_ACCOUNT.test(account)) { throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.') } - const amount = toBaseUnits(amountText, tokenIn.decimals) +} + +/** + * The preview half of every 1Click flow: a dry quote, checked against what was asked and + * against live prices. A dry quote makes no deposit address, so the race can run it for + * every venue, and Review runs it again before the real quote. The caller has already + * checked the account belongs to the origin chain. + */ +export async function previewOneClick( + amountText: string, + origin: QuoteOrigin, + destination: Destination, + account: string +) { + const amount = toBaseUnits(amountText, origin.decimals) const shared = { - originAsset: assetIdOf(tokenIn), + originAsset: origin.assetId, destinationAsset: destination.assetId, amount, account, @@ -244,30 +290,81 @@ export async function prepareOneClickDeposit( const dryDeadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString() const preview = await requestQuote({ ...shared, dry: true, deadline: dryDeadline }) assertQuoteMatches(preview, { ...shared, dry: true, deadline: dryDeadline }) - await assertFairValue(preview, tokenIn, destination) + await assertFairValue(preview, origin, destination) + return { shared, preview } +} +/** + * The real quote, which creates a deposit address, under the same checks as the preview. + * The address itself is left to the caller, because its shape depends on the chain. + */ +export async function liveOneClick( + shared: Awaited>['shared'], + origin: QuoteOrigin, + destination: Destination +) { const deadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString() const live = await requestQuote({ ...shared, dry: false, deadline }) assertQuoteMatches(live, { ...shared, dry: false, deadline }) - const loss = await assertFairValue(live, tokenIn, destination) + const loss = await assertFairValue(live, origin, destination) + return { live, loss, deadline } +} - const { depositAddress, depositMemo } = live.quote - if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) { +export function assertNoDepositMemo(envelope: QuoteEnvelope): void { + const { depositMemo } = envelope.quote + if (depositMemo !== undefined && depositMemo !== '') { throw new ChainActionRefused( - "NEAR Intents' deposit address didn't look right, so it was not used. Nothing was signed or sent." + 'NEAR Intents asked for a deposit memo, which this app does not send. Nothing was signed or sent.' ) } - if (depositMemo !== undefined && depositMemo !== '') { +} + +function venueQuoteFrom(preview: QuoteEnvelope, destination: Destination): VenueQuote { + return { + receive: { + amount: formatUnits(preview.quote.amountOut, destination.decimals), + symbol: destination.symbol, + }, + valueOutUsd: Number(preview.quote.amountOutUsd), + } +} + +/** What a dry quote says the swap would give back. Never reads a deposit address. */ +export async function quoteOneClick( + amountText: string, + tokenIn: ChainToken, + destination: Destination, + account: string +): Promise { + assertNearAccount(account) + const { preview } = await previewOneClick(amountText, nearOrigin(tokenIn), destination, account) + return venueQuoteFrom(preview, destination) +} + +/** The quote flow behind every 1Click venue. Only the destination and the wording differ. */ +export async function prepareOneClickDeposit( + flow: DepositFlow, + amountText: string, + tokenIn: ChainToken, + destination: Destination, + account: string +): Promise { + assertNearAccount(account) + const origin = nearOrigin(tokenIn) + const { shared } = await previewOneClick(amountText, origin, destination, account) + const amount = shared.amount + + const { live, loss, deadline } = await liveOneClick(shared, origin, destination) + + const { depositAddress } = live.quote + if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) { throw new ChainActionRefused( - 'NEAR Intents asked for a deposit memo, which this app does not send. Nothing was signed or sent.' + "NEAR Intents' deposit address didn't look right, so it was not used. Nothing was signed or sent." ) } + assertNoDepositMemo(live) const transaction = await buildDepositTransaction(tokenIn, account, depositAddress, amount) - const refundFee = live.quote.refundFee - const refundNote = isPositive(refundFee) - ? `, minus a fee of ${formatUnits(refundFee, tokenIn.decimals)} ${tokenIn.symbol}` - : '' return { venueId: flow.venueId, @@ -282,7 +379,7 @@ export async function prepareOneClickDeposit( }, notes: [ costNote(live, loss, destination.symbol, destination.decimals), - `Your funds go to a one-time deposit address from 1Click, which this app can't check on-chain. Sign within 5 minutes, because a late deposit may be refunded instead of swapped. If the swap can't finish, 1Click returns your funds to ${account}${refundNote}.`, + depositNote(live, tokenIn, account), ...flow.extraNotes, ], signing: { family: 'near', transactions: [transaction] }, @@ -290,13 +387,33 @@ export async function prepareOneClickDeposit( } } -async function prepareSwap(request: ChainActionRequest, account: string): Promise { +function swapTokens(request: ChainActionRequest): { tokenIn: ChainToken; tokenOut: ChainToken } { const tokenIn = tokenBySymbol(nearTokens, request.tokenIn) const tokenOut = tokenBySymbol(nearTokens, request.tokenOut ?? '') if (request.kind !== 'swap' || tokenIn === undefined || tokenOut === undefined) { throw new ChainActionRefused('NEAR Intents here swaps between NEAR, USDC and USDT.') } if (tokenIn.id === tokenOut.id) throw new ChainActionRefused('Those are the same token.') + return { tokenIn, tokenOut } +} + +async function quoteSwap(request: ChainActionRequest, account: string): Promise { + const { tokenIn, tokenOut } = swapTokens(request) + return quoteOneClick( + request.amount, + tokenIn, + { + assetId: assetIdOf(tokenOut), + symbol: tokenOut.symbol, + decimals: tokenOut.decimals, + recipientType: 'DESTINATION_CHAIN', + }, + account + ) +} + +async function prepareSwap(request: ChainActionRequest, account: string): Promise { + const { tokenIn, tokenOut } = swapTokens(request) const toNear = tokenOut.id === WRAP_NEAR return prepareOneClickDeposit( { @@ -309,9 +426,7 @@ async function prepareSwap(request: ChainActionRequest, account: string): Promis : toNear ? `Swap ${amount} ${token.symbol} for NEAR through NEAR Intents, delivered to your NEAR account as NEAR or wNEAR.` : `Swap ${amount} ${token.symbol} for ${tokenOut.symbol} through NEAR Intents, into your NEAR account.`, - extraNotes: [ - 'NEAR Intents had an exploit of about $3.8M around 1 October 2026 in its bridge links. Funds were reported returned, but bridges and solvers it relies on remain a risk.', - ], + extraNotes: [ONECLICK_RISK_NOTE], }, request.amount, tokenIn, @@ -358,11 +473,16 @@ export async function submitOneClick(input: SubmitInput): Promise const hash = signed.hashes[signed.hashes.length - 1] const depositAddress = context?.['depositAddress'] if (hash === undefined || !NEAR_TX_HASH.test(hash)) { - return { ok: false, error: 'The wallet did not return a transaction hash to follow.' } + return { + ok: false, + pending: true, + error: 'The wallet did not return a transaction hash to follow.', + } } if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) { return { ok: false, + pending: true, error: `This swap's deposit address was lost, so it can't be followed from here. Your transaction is ${hash}. Check your wallet before trying again.`, } } @@ -382,6 +502,7 @@ export async function submitOneClick(input: SubmitInput): Promise if (outcome.signer !== account || !paysDeposit) { return { ok: false, + pending: true, error: `Transaction ${hash} is not this account's deposit for this swap, so it was not reported to NEAR Intents. Check your wallet's activity.`, } } @@ -394,7 +515,11 @@ export async function submitOneClick(input: SubmitInput): Promise lastStatus = body?.status ?? lastStatus const recipient = body?.quoteResponse?.quoteRequest?.recipient if (recipient !== undefined && recipient !== account) { - return { ok: false, error: 'That deposit address belongs to a different swap.' } + return { + ok: false, + pending: true, + error: 'That deposit address belongs to a different swap.', + } } switch (body?.status) { case 'SUCCESS': @@ -407,11 +532,13 @@ export async function submitOneClick(input: SubmitInput): Promise case 'FAILED': return { ok: false, + pending: true, error: `The swap failed after your deposit (${hash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`, } case 'INCOMPLETE_DEPOSIT': return { ok: false, + pending: true, error: `NEAR Intents received less than the quote needed, so it won't swap. What arrived is due back at ${account}. ${EXPLORER_NOTE} ${depositAddress}.`, } default: @@ -427,6 +554,7 @@ export const oneClickVenue: ChainVenue = { id: ONECLICK_VENUE_ID, name: 'NEAR Intents 1Click', kinds: ['swap'], + quote: quoteSwap, prepare: prepareSwap, assertSafe: assertOneClickSafe, submit: submitOneClick, diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts new file mode 100644 index 00000000..23295f94 --- /dev/null +++ b/apps/dapp/lib/chains/oneclick-origin.ts @@ -0,0 +1,627 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +import { TransactionDataBuilder } from '@mysten/sui/transactions' +import { fromBase64, normalizeStructTag } from '@mysten/sui/utils' + +import { walletSends } from '../weave/cashout-view' +import { solanaDeposit } from '../weave/deposit/solana' +import { suiDeposit } from '../weave/deposit/sui' +import type { DepositChain, DepositExpectation } from '../weave/types' +import { evmDeposit } from './evm/deposit' +import { EVM_HASH, sameAddress } from './evm/submit' +import { EVM_CHAINS, EVM_NATIVE_ID, evmExplorerUrl, type EvmChain } from './evm/tokens' +import { depositStatus, listTokens, notifyDeposit, type OneClickToken } from './near/oneclick' +import { + assertNoDepositMemo, + costNote, + depositNote, + EXPLORER_NOTE, + liveOneClick, + ONECLICK_RISK_NOTE, + previewOneClick, + type CostWording, + type Destination, + type QuoteOrigin, +} from './near/swap' +import { formatUnits } from './near/tokens' +import { explorerUrl as solanaExplorerUrl, feePayerSignature } from './solana/submit' +import { SOLANA_TOKENS } from './solana/tokens' +import { SUI_TOKENS } from './sui/tokens' +import { + ChainActionRefused, + tokenBySymbol, + withArticle, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SignedPayload, + type SigningPayload, + type SubmitInput, + type SubmitResult, + type VenueQuote, +} from './types' + +/** + * NEAR Intents 1Click as a swap route on a chain other than NEAR. The wallet signs one + * transfer to a one-time deposit address, and 1Click pays the swap out on the same chain. + * + * The browser carries `context` between prepare and submit, so nothing in it is believed + * until `readIssued` proves this server wrote it. See `proofOf`. + */ + +export type OneClickOriginChain = 'solana' | 'sui' | 'avalanche' | 'monad' + +const VENUE_NAME = 'NEAR Intents 1Click' + +/** What 1Click calls each chain in its token list. */ +const BLOCKCHAIN: Record = { + solana: 'sol', + sui: 'sui', + avalanche: 'avax', + monad: 'monad', +} + +/** All that's spent through 1Click on a chain with a swap of its own, which takes the native coins. */ +const SPENDABLE = new Set(['USDC', 'USDT']) + +/** + * The limits inside the submit route's 60 seconds. The status wait counts from the start and + * includes the confirm wait. One last status request and a pause on top keep it under 60. + */ +const CONFIRM_WAIT_MS = 40_000 +const STATUS_WAIT_MS = 50_000 +const STATUS_POLL_MS = 2_000 + +const COST_WORDING: CostWording = { + withdrawFor: 'to deliver it to you', + total: "After 1Click's costs, and before your wallet's network costs,", +} + +/** + * Rent for a Solana token account of 165 bytes, in SOL. Every swap pays a new deposit address, + * whose token account the transaction opens from this wallet and nobody returns. It is treated + * as always due: a dry quote has no address to look up, and a one-time address has no account + * yet, so a read at Review would only confirm it. If one somehow exists the transaction opens + * nothing and this overstates by the rent, which errs against 1Click, not the person. + */ +const SOLANA_DEPOSIT_RENT_SOL = '0.00203928' + +const SOLANA_RENT_NOTE = + "Your wallet also pays about 0.00204 SOL in rent to open a token account at the deposit address, and you don't get it back. Every swap uses a new address, so this is due each time." + +interface Origin { + chainName: string + family: 'solana' | 'sui' | 'evm' + tokens: readonly ChainToken[] + /** The token id that stands for the native coin, which 1Click lists with no contract. */ + nativeId: string + /** + * Whether that id is the native coin itself. A mint for wrapped SOL is not SOL: paying out to + * it would leave wrapped tokens while the card says SOL. Sui's coin type is SUI itself. + */ + nativeIdIsTheCoin: boolean + /** What the wallet pays beyond the transfer and the fee, with the sentence that says so. */ + walletCost?: { amount: string; symbol: string; note: string } + /** The chain's own swap, which takes what 1Click doesn't here. Absent where 1Click is the only one. */ + ownSwap?: string + deposit: Pick + /** + * Reads what the wallet holds before a deposit address is asked for, where the build won't. + * Answers with any notes the card should carry about what it read. + */ + assertFunds?: (account: string, token: ChainToken, amount: string) => Promise + /** The chain's identifier for signed bytes, known before the network has answered. */ + hashOf: (signed: SignedPayload) => string | undefined + explorerUrl: (hash: string) => string +} + +function suiExplorerUrl(hash: string): string { + return `https://suiscan.xyz/mainnet/tx/${hash}` +} + +function evmOrigin(chain: EvmChain): Origin { + const deposit = evmDeposit(chain) + const { name, tokens } = EVM_CHAINS[chain] + return { + chainName: name, + family: 'evm', + tokens, + nativeId: EVM_NATIVE_ID, + nativeIdIsTheCoin: false, + deposit, + assertFunds: deposit.assertFunds, + hashOf: (signed) => + signed.family === 'evm' && EVM_HASH.test(signed.hash) ? signed.hash : undefined, + explorerUrl: (hash) => evmExplorerUrl(chain, hash), + } +} + +const ORIGINS: Record = { + avalanche: evmOrigin('avalanche'), + monad: evmOrigin('monad'), + solana: { + chainName: 'Solana', + family: 'solana', + tokens: SOLANA_TOKENS, + nativeId: 'So11111111111111111111111111111111111111112', + nativeIdIsTheCoin: false, + walletCost: { amount: SOLANA_DEPOSIT_RENT_SOL, symbol: 'SOL', note: SOLANA_RENT_NOTE }, + ownSwap: 'Jupiter', + deposit: solanaDeposit, + hashOf: (signed) => + signed.family === 'solana' ? feePayerSignature(signed.signedTransaction) : undefined, + explorerUrl: solanaExplorerUrl, + }, + sui: { + chainName: 'Sui', + family: 'sui', + tokens: SUI_TOKENS, + nativeId: '0x2::sui::SUI', + nativeIdIsTheCoin: true, + ownSwap: 'Cetus', + deposit: suiDeposit, + hashOf: (signed) => + signed.family === 'sui' + ? TransactionDataBuilder.getDigestFromBytes(fromBase64(signed.transaction)) + : undefined, + explorerUrl: suiExplorerUrl, + }, +} + +/** The form two copies of a contract compare in. Case is part of a base58 mint, so it stays. */ +function normalizeContract(chain: OneClickOriginChain, contract: string): string { + if (chain === 'solana') return contract + if (chain !== 'sui') return contract.toLowerCase() + try { + return normalizeStructTag(contract) + } catch { + return contract + } +} + +/** + * The 1Click asset for a token, matched on chain and contract. The list repeats symbols, and + * anyone can name a token USDC, so a symbol alone never matches. Two entries for one contract + * leave no way to say which is real, so that is no match either. + * + * A native coin has no contract, so it matches only as the chain's own native token id, against + * the one entry with its symbol and no contract. A contract that merely wraps it never matches, + * since what would arrive is not what the card says. + */ +export async function oneClickAssetId( + chain: OneClickOriginChain, + token: ChainToken +): Promise { + const entries = (await listTokens()).filter( + (entry) => entry.blockchain === BLOCKCHAIN[chain] && entry.decimals === token.decimals + ) + const wanted = normalizeContract(chain, token.id) + const hasContract = (entry: OneClickToken): boolean => + entry.contractAddress !== undefined && + entry.contractAddress !== null && + entry.contractAddress !== '' + const isContract = (entry: OneClickToken): boolean => + hasContract(entry) && normalizeContract(chain, entry.contractAddress as string) === wanted + + const origin = ORIGINS[chain] + const matching = + token.id === origin.nativeId + ? entries.filter( + (entry) => + entry.symbol.toUpperCase() === token.symbol.toUpperCase() && + (!hasContract(entry) || (origin.nativeIdIsTheCoin && isContract(entry))) + ) + : entries.filter(isContract) + return matching.length === 1 ? matching[0]?.assetId : undefined +} + +function secret(env: Record = process.env): string | undefined { + const value = env['AUTH_SECRET'] + return value !== undefined && value.length >= 32 ? value : undefined +} + +/** Whether a chain should offer this venue at all. Nothing can be issued safely without it. */ +export function canIssueOneClick(env: Record): boolean { + return secret(env) !== undefined +} + +/** Without the secret nothing could be issued safely at Review, so no route is offered at all. */ +function signingKey(): string { + const key = secret() + if (key === undefined) { + throw new ChainActionRefused(`${VENUE_NAME} isn't available here right now.`) + } + return key +} + +interface Issued { + depositAddress: string + /** The token's id. */ + token: string + /** Base units. */ + amount: string + deadline: string +} + +/** + * Proves a deposit came from this server. The browser hands `context` back at submit, and + * could change it along with the bytes it wants sent, so every field that decides where funds + * go is signed here, for this venue and this account. The prefix keeps this signature from + * ever passing as a session's, which shares the secret. + */ +function proofOf(venueId: string, account: string, issued: Issued, key: string): string { + const message = JSON.stringify([ + venueId, + account, + issued.token, + issued.amount, + issued.depositAddress, + issued.deadline, + ]) + return createHmac('sha256', key).update(`oneclick-origin:${message}`).digest('base64url') +} + +function sameProof(given: string, expected: string): boolean { + const a = Buffer.from(given) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + +function notIssued(): never { + throw new ChainActionRefused( + "This swap's details are not what this app issued, so nothing was sent. Get a fresh quote and sign that." + ) +} + +/** + * The deposit this server issued, or a refusal. Never returns anything it did not sign. + * + * `deadlineMatters` is false once a wallet has sent for itself: an expired quote then can't mean + * nothing was sent, so the transfer is followed and 1Click says what became of it. + */ +function readIssued( + origin: Origin, + venueId: string, + account: string, + context: Record | undefined, + deadlineMatters = true +): DepositExpectation { + const key = secret() + const depositAddress = context?.['depositAddress'] + const tokenId = context?.['token'] + const amount = context?.['amount'] + const deadline = context?.['deadline'] + const proof = context?.['proof'] + if ( + key === undefined || + depositAddress === undefined || + tokenId === undefined || + amount === undefined || + deadline === undefined || + proof === undefined || + !sameProof( + proof, + proofOf(venueId, account, { depositAddress, token: tokenId, amount, deadline }, key) + ) + ) { + return notIssued() + } + const token = origin.tokens.find((t) => t.id === tokenId) + if (token === undefined) return notIssued() + if (deadlineMatters && !(Date.parse(deadline) > Date.now())) { + throw new ChainActionRefused( + 'This quote expired before it was sent, so nothing was sent. Get a fresh quote and sign right away.' + ) + } + return { + account, + depositAddress, + memo: undefined, + amount: formatUnits(amount, token.decimals, token.decimals), + token, + } +} + +interface Route { + tokenIn: ChainToken + tokenOut: ChainToken + origin: QuoteOrigin + destination: Destination +} + +/** What the swap would touch, or the sentence for why not. A refusal drops the route from the race. */ +async function routeFor( + origin: Origin, + chain: OneClickOriginChain, + request: ChainActionRequest, + account: string +): Promise { + if (request.kind !== 'swap') { + throw new ChainActionRefused(`${VENUE_NAME} swaps tokens. It can't ${request.kind} here.`) + } + if (!origin.deposit.isAccount(account)) { + throw new ChainActionRefused(`That is not ${withArticle(origin.chainName)} wallet address.`) + } + const tokenIn = tokenBySymbol(origin.tokens, request.tokenIn) + const tokenOut = tokenBySymbol(origin.tokens, request.tokenOut ?? '') + if (tokenIn === undefined || tokenOut === undefined) { + throw new ChainActionRefused(`I can only swap tokens I have confirmed on ${origin.chainName}.`) + } + if (tokenIn.id === tokenOut.id) throw new ChainActionRefused('Those are the same token.') + if (origin.ownSwap !== undefined && !SPENDABLE.has(tokenIn.symbol)) { + throw new ChainActionRefused( + `${VENUE_NAME} here spends USDC or USDT, not ${tokenIn.symbol}. ${origin.ownSwap} can swap ${tokenIn.symbol}.` + ) + } + + let ids: [string | undefined, string | undefined] + try { + ids = await Promise.all([oneClickAssetId(chain, tokenIn), oneClickAssetId(chain, tokenOut)]) + } catch { + throw new ChainActionRefused( + "NEAR Intents isn't answering right now. Try again in a minute. Nothing was signed or sent." + ) + } + const [originAsset, destinationAsset] = ids + if (originAsset === undefined || destinationAsset === undefined) { + const missing = originAsset === undefined ? tokenIn.symbol : tokenOut.symbol + throw new ChainActionRefused( + `${VENUE_NAME} doesn't list ${missing} on ${origin.chainName}. Nothing was signed or sent.` + ) + } + return { + tokenIn, + tokenOut, + origin: { assetId: originAsset, decimals: tokenIn.decimals }, + destination: { + assetId: destinationAsset, + symbol: tokenOut.symbol, + decimals: tokenOut.decimals, + recipientType: 'DESTINATION_CHAIN', + }, + } +} + +function stillProcessing(hash: string, depositAddress: string): SubmitResult { + return { + ok: false, + pending: true, + error: `Your deposit was sent (${hash}) and the swap is still processing, which can take up to 15 minutes. Don't send it again. ${EXPLORER_NOTE} ${depositAddress}.`, + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Sends through the chain's own deposit submit, which checks the bytes again first. Its + * refusals only ever come before the send, so they prove nothing moved. Anything else thrown, + * or no answer inside the limit, leaves open that the transfer went out. So does any refusal + * where the wallet sent for itself, since there was no send here for it to come before. + */ +async function send( + origin: Origin, + signed: SignedPayload, + expectation: DepositExpectation +): Promise { + const unconfirmed = (): SubmitResult => { + const hash = origin.hashOf(signed) + const where = hash === undefined ? "your wallet's activity" : origin.explorerUrl(hash) + return { + ok: false, + pending: true, + error: `This may have been sent and isn't confirmed yet, so don't send it again. Check ${where} first.`, + } + } + let timer: ReturnType | undefined + const limit = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), CONFIRM_WAIT_MS) + }) + try { + return ( + (await Promise.race([origin.deposit.submit(signed, expectation), limit])) ?? unconfirmed() + ) + } catch (e) { + return e instanceof ChainActionRefused && !walletSends(origin.family) + ? { ok: false, error: e.message } + : unconfirmed() + } finally { + clearTimeout(timer) + } +} + +/** The venue behind a 1Click swap from `chain`. */ +export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { + const venueId = `1click-${chain}` + + const origin = ORIGINS[chain] + + async function quote(request: ChainActionRequest, account: string): Promise { + const route = await routeFor(origin, chain, request, account) + signingKey() + const { preview } = await previewOneClick( + request.amount, + route.origin, + route.destination, + account + ) + // Only what arrives, not 1Click's own dollar figure. The other routes on this chain are + // valued by the chain's price source, and a gap between two sources could outweigh a gap + // between two routes, letting the one that delivers less win. + const cost = origin.walletCost + return { + receive: { + amount: formatUnits(preview.quote.amountOut, route.destination.decimals), + symbol: route.destination.symbol, + }, + ...(cost !== undefined ? { walletCost: { amount: cost.amount, symbol: cost.symbol } } : {}), + } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { + tokenIn, + tokenOut, + origin: spending, + destination, + } = await routeFor(origin, chain, request, account) + const key = signingKey() + // Before the real quote, so someone who can't pay never gets a deposit address made. + const fundsNotes = (await origin.assertFunds?.(account, tokenIn, request.amount)) ?? [] + const { shared } = await previewOneClick(request.amount, spending, destination, account) + const { live, loss, deadline } = await liveOneClick(shared, spending, destination) + + const { depositAddress } = live.quote + if (depositAddress === undefined || !origin.deposit.isDepositAddress(depositAddress)) { + throw new ChainActionRefused( + "NEAR Intents' deposit address didn't look right, so it was not used. Nothing was signed or sent." + ) + } + assertNoDepositMemo(live) + + const signing = await origin.deposit.build({ + account, + depositAddress, + memo: undefined, + amount: request.amount, + token: tokenIn, + }) + + const issued: Issued = { depositAddress, token: tokenIn.id, amount: shared.amount, deadline } + return { + venueId, + venueName: VENUE_NAME, + kind: 'swap', + summary: `Swap ${request.amount} ${tokenIn.symbol} for ${tokenOut.symbol} through NEAR Intents, into your ${origin.chainName} account.`, + pay: { amount: request.amount, symbol: tokenIn.symbol }, + receive: { + amount: formatUnits(live.quote.amountOut, destination.decimals), + symbol: destination.symbol, + approximate: true, + }, + notes: [ + costNote(live, loss, destination.symbol, destination.decimals, COST_WORDING), + ...(origin.walletCost !== undefined ? [origin.walletCost.note] : []), + ...fundsNotes, + depositNote(live, tokenIn, account), + ONECLICK_RISK_NOTE, + ], + signing, + context: { ...issued, proof: proofOf(venueId, account, issued, key) }, + } + } + + function assertSafe( + payload: SigningPayload, + account: string, + context?: Record + ): void { + origin.deposit.assertSafe(payload, readIssued(origin, venueId, account, context)) + } + + /** + * Every `{ ok: false }` without `pending` here comes before the transfer was sent, or from + * the chain's own submit having proved it did not move. Once the network has the transfer, + * a swap that fails at 1Click still has funds in flight or on their way back, so that is + * always pending and the latch stays closed. + * + * A wallet that sends for itself has already sent by the time anything arrives here. So on + * those chains a refusal below can't say nothing was sent, and it is pending too. + */ + async function submit(input: SubmitInput): Promise { + const { account, signed, context } = input + const sends = walletSends(origin.family) + const notFollowed = (why: string): SubmitResult => { + const hash = origin.hashOf(signed) + return { + ok: false, + pending: true, + error: `${why} so it can't be followed from here. Your wallet may already have sent it${hash === undefined ? '' : ` as ${hash}`}, so check its activity before trying again.`, + } + } + if (signed.family !== origin.family) { + return sends + ? notFollowed(`That is not ${withArticle(origin.chainName)} transaction,`) + : { + ok: false, + error: `That is not ${withArticle(origin.chainName)} transaction, so nothing was sent.`, + } + } + let expectation: DepositExpectation + try { + expectation = readIssued(origin, venueId, account, context, !sends) + } catch (e) { + if (e instanceof ChainActionRefused) { + return sends + ? notFollowed("This swap's details are not what this app issued,") + : { ok: false, error: e.message } + } + throw e + } + const { depositAddress } = expectation + + const startedAt = Date.now() + const sent = await send(origin, signed, expectation) + if (!sent.ok) { + return sent.pending === true + ? { ...sent, error: `${sent.error} ${EXPLORER_NOTE} ${depositAddress}.` } + : sent + } + + const { hash: sentHash } = sent + await notifyDeposit(depositAddress, sentHash) + + // A hex address may come back in another casing, and on other chains case is part of it. + const isAccount = (recipient: string): boolean => + origin.family === 'evm' ? sameAddress(recipient, account) : recipient === account + + while (Date.now() - startedAt < STATUS_WAIT_MS) { + const body = await depositStatus(depositAddress) + const recipient = body?.quoteResponse?.quoteRequest?.recipient + if (recipient !== undefined && !isAccount(recipient)) { + return { + ok: false, + pending: true, + error: `That deposit address belongs to a different swap. Your transfer is ${sentHash}. Check your wallet's activity before trying again.`, + } + } + switch (body?.status) { + case 'SUCCESS': + return sent + case 'REFUNDED': + return { + ok: false, + pending: true, + error: `The swap did not go through, and NEAR Intents says your funds were refunded to ${account}. Check your balance before trying again. ${EXPLORER_NOTE} ${depositAddress}.`, + } + case 'FAILED': + return { + ok: false, + pending: true, + error: `The swap failed after your deposit (${sentHash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`, + } + case 'INCOMPLETE_DEPOSIT': + return { + ok: false, + pending: true, + error: `NEAR Intents received less than the quote needed, so it won't swap. What arrived is due back at ${account}. ${EXPLORER_NOTE} ${depositAddress}.`, + } + default: + await delay(STATUS_POLL_MS) + } + } + return stillProcessing(sentHash, depositAddress) + } + + return { + id: venueId, + name: VENUE_NAME, + kinds: ['swap'], + quote, + prepare, + assertSafe, + submit, + } +} diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index 4e822159..fac32b39 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -22,8 +22,10 @@ const STAKE = new RegExp( const ACTION_VERB = /\b(swap|convert|exchange|trade|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i +/** Plain decimal text: no separators, and a leading zero on ".5", which nothing downstream reads. */ function amountOf(text: string): string { - return text.replace(/,/g, '') + const plain = text.replace(/,/g, '') + return plain.startsWith('.') ? `0${plain}` : plain } /** diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index a30eac8e..f78877a0 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -1,20 +1,47 @@ +import { CHAIN_DESCRIPTORS } from '@intent/config' + import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade-cap' import { ChainActionRefused, tokenBySymbol, type ChainActionRequest, type ChainModule, + type ChainToken, type ChainVenue, type PreparedAction, } from './types' +/** Keys only: each chain's name is its slug, and reading Stellar's descriptor at load would throw. */ +const CHAIN_NAMES = new Set(Object.keys(CHAIN_DESCRIPTORS)) + +/** + * The venue a request names, in lower case. "swap 10 USDC to MON on monad" reads "monad" as one, + * but it only says where the person already is, so the chain they are on counts as none named. + */ +export function namedVenue( + chainApi: Pick, + request: Pick +): string | undefined { + const wanted = request.venue?.trim().toLowerCase() + if (wanted === undefined) return undefined + const { slug, name } = CHAIN_DESCRIPTORS[chainApi.chain] + return wanted === slug || wanted === name.toLowerCase() ? undefined : wanted +} + +/** + * Whether a venue answers to that name. Another chain's name answers to nothing, so nothing runs + * here that was asked for there, even where it is part of a venue's name, as NEAR is of 1Click's. + */ +export function venueAnswersTo(venue: Pick, wanted: string): boolean { + if (CHAIN_NAMES.has(wanted)) return false + return venue.id.toLowerCase() === wanted || venue.name.toLowerCase().includes(wanted) +} + function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVenue { const usable = chainApi.venues.filter((v) => v.kinds.includes(request.kind)) - if (request.venue !== undefined) { - const wanted = request.venue.trim().toLowerCase() - const named = usable.find( - (v) => v.id.toLowerCase() === wanted || v.name.toLowerCase().includes(wanted) - ) + const wanted = namedVenue(chainApi, request) + if (wanted !== undefined) { + const named = usable.find((v) => venueAnswersTo(v, wanted)) if (named === undefined) { const names = usable.map((v) => v.name).join(', ') throw new ChainActionRefused( @@ -34,23 +61,19 @@ function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVen } /** - * Prepares an action, with the same order of checks as every other money route: - * known tokens, a dollar value under the cap, then the build, then a check of - * what the venue's API returned before anyone is asked to sign it. - * - * The cap always reads as mainnet, because these chains only run on mainnet. The - * Stellar network a deployment defaults to says nothing about Solana's funds. + * Refuses a token this chain does not list as verified, in the same words + * wherever it is asked: the signing route and the agent race both call this, so + * a refused token reads the same in each and no agent is ever consulted about it. */ -export async function prepareChainAction( - chainApi: ChainModule, - request: ChainActionRequest, - account: string, - env: Record = process.env -): Promise { +export function assertKnownTokens(chainApi: ChainModule, request: ChainActionRequest): ChainToken { + const hintFor = (symbol: string | undefined): string => { + const hint = chainApi.tokenHints?.[(symbol ?? '').trim().toUpperCase()] + return hint === undefined ? '' : ` ${hint}` + } const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn) if (tokenIn === undefined) { throw new ChainActionRefused( - `I can't confirm ${request.tokenIn} as a verified token on this chain, so I won't touch it.` + `I can't confirm ${request.tokenIn} as a verified token on this chain, so I won't touch it.${hintFor(request.tokenIn)}` ) } if (request.kind === 'swap') { @@ -59,14 +82,30 @@ export async function prepareChainAction( tokenBySymbol(chainApi.tokens, request.tokenOut) === undefined ) { throw new ChainActionRefused( - `I can't confirm ${request.tokenOut ?? 'that token'} as a verified token on this chain, so I won't trade it.` + `I can't confirm ${request.tokenOut ?? 'that token'} as a verified token on this chain, so I won't trade it.${hintFor(request.tokenOut)}` ) } if (request.tokenOut.toUpperCase() === request.tokenIn.toUpperCase()) { throw new ChainActionRefused('Those are the same token.') } } + return tokenIn +} +/** + * Refuses an amount that is not a positive number, a token with no live price, + * and a trade over the dollar cap. Shared with the agent race so a refused + * amount is refused in the same words before any agent or venue is called. + * + * The cap always reads as mainnet, because these chains only run on mainnet. The + * Stellar network a deployment defaults to says nothing about Solana's funds. + */ +export async function assertAmountWithinCap( + chainApi: ChainModule, + request: ChainActionRequest, + tokenIn: ChainToken, + env: Record = process.env +): Promise { const price = await chainApi.priceUsd(tokenIn) const amount = Number(request.amount) if (!Number.isFinite(amount) || amount <= 0) { @@ -84,9 +123,28 @@ export async function prepareChainAction( : 'This trade could not be valued in dollars right now, so it was not built. Try again shortly.' ) } +} + +/** + * Prepares an action, with the same order of checks as every other money route: + * known tokens, a dollar value under the cap, then the build, then a check of + * what the venue's API returned before anyone is asked to sign it. + * + * The cap always reads as mainnet, because these chains only run on mainnet. The + * Stellar network a deployment defaults to says nothing about Solana's funds. + */ +export async function prepareChainAction( + chainApi: ChainModule, + request: ChainActionRequest, + account: string, + env: Record = process.env +): Promise { + const tokenIn = assertKnownTokens(chainApi, request) + + await assertAmountWithinCap(chainApi, request, tokenIn, env) const venue = pickVenue(chainApi, request) const prepared = await venue.prepare(request, account) - venue.assertSafe(prepared.signing, account) + venue.assertSafe(prepared.signing, account, prepared.context) return prepared } diff --git a/apps/dapp/lib/chains/registry.ts b/apps/dapp/lib/chains/registry.ts index 9df5fb47..c7473cc6 100644 --- a/apps/dapp/lib/chains/registry.ts +++ b/apps/dapp/lib/chains/registry.ts @@ -1,3 +1,4 @@ +import { avalancheModule, monadModule } from './evm/module' import { nearModule } from './near/module' import { solanaModule } from './solana/module' import { suiModule } from './sui/module' @@ -7,6 +8,8 @@ const MODULES: Record = { solana: solanaModule, sui: suiModule, near: nearModule, + avalanche: avalancheModule, + monad: monadModule, } export function chainModule(chain: string): ChainModule | undefined { diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts new file mode 100644 index 00000000..14575136 --- /dev/null +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -0,0 +1,303 @@ +import { RACE_HEADER } from '../api/network-fetch' +import type { PreparedAction, SignedOutcome, SignedPayload, SigningPayload } from './types' +import { + CHECK_WALLET, + accountMismatch, + afterSubmit, + afterWallet, + depositExplorerUrl, + walletSends, +} from '../weave/cashout-view' +import { isCashoutChain } from '../weave/types' +import { evmExplorerUrl } from './evm/tokens' + +type Family = SigningPayload['family'] + +export { accountMismatch } + +/** + * Whether handing the payload to the wallet may already move money. A wallet + * that broadcasts for itself can send and then report an error or a decline, + * so from the moment it is called the action must not be signed again. + */ +export function latchBeforeSign(family: Family): boolean { + return walletSends(family) +} + +export const QUOTE_EXPIRED = + 'This quote expired before you signed, so your wallet was not asked and nothing was sent. Send your request again for a fresh one.' + +/** + * Whether a card has outlived its quote, on a chain where this is the last chance to say so. The + * server checks the deadline before it sends signed bytes, but an EVM wallet sends for itself, and + * a late deposit may be refunded instead of swapped. NEAR's wallet sends too, and is left as it was. + */ +export function quoteExpiredBeforeSigning( + prepared: Pick, + now: number = Date.now() +): boolean { + const deadline = prepared.context?.['deadline'] + return ( + prepared.signing.family === 'evm' && deadline !== undefined && !(Date.parse(deadline) > now) + ) +} + +export type AfterSigning = + | { next: 'submit'; signed: SignedPayload } + /** Signed-bytes wallet declined: nothing left the device, so the card stays on offer. */ + | { next: 'review' } + /** Signed-bytes wallet failed before anything was sent. */ + | { next: 'failed'; error: string } + /** Self-sending wallet gave no confirmation: it may have gone out. */ + | { next: 'unknown'; error: string } + +export function afterSigning(outcome: SignedOutcome, family: Family): AfterSigning { + const step = afterWallet(outcome, family) + if (step.next === 'check') { + return { + next: 'unknown', + error: `${CHECK_WALLET} If it did not go out, send a new message to start again.`, + } + } + return step +} + +/** + * Hands signed bytes to the server. A dropped connection or an answer that can't be read, such + * as the platform's own page when it stops the function, says nothing about whether they went + * out, so it comes back as no answer, which `afterSubmitting` reads as pending. + * The race the action came from, when it came from one, rides along so the log can credit it. + */ +export async function submitSigned(url: string, body: unknown, raceId?: string): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(raceId !== undefined ? { [RACE_HEADER]: raceId } : {}), + }, + body: JSON.stringify(body), + }) + return (await res.json()) as unknown + } catch { + return undefined + } +} + +export type AfterSubmitting = + | { phase: 'settled'; hash: string; explorerUrl: string | undefined; latched: true } + | { phase: 'pending'; error: string; latched: true } + | { phase: 'failed'; error: string; latched: false } + +/** + * The server's answer to submitted signed bytes. Only an explicit clean refusal + * releases the latch; every other answer leaves open that the money moved. + */ +export function afterSubmitting(result: unknown, family: Family): AfterSubmitting { + const step = afterSubmit(result, family) + if (step.next === 'tracking') { + return { + phase: 'settled', + hash: step.hash, + explorerUrl: step.explorerUrl === '' ? undefined : step.explorerUrl, + latched: true, + } + } + if (step.next === 'pending') { + const said = (result as { error?: unknown } | null)?.error + return { + phase: 'pending', + error: + typeof said === 'string' && said.includes(' ') + ? said + : "This may have gone through. Check your wallet's activity before trying again.", + latched: true, + } + } + return { phase: 'failed', error: step.error, latched: false } +} + +/** + * A signature that was asked for, kept past a reload so a person who comes back and types the same + * thing again is warned before paying twice. It warns and never blocks, so storage that fails + * changes nothing. It is dropped only by an answer that proves nothing moved. + */ +export interface SignAttempt { + chain: string + account: string + tokenIn: string + amount: string + at: number + hash?: string +} + +export type AttemptStorage = Pick + +export const ATTEMPT_WINDOW_MS = 15 * 60_000 + +const ATTEMPTS_KEY = 'intent.chain.attempts' + +/** Reading `localStorage` can itself throw when a browser denies it. */ +export function attemptStorage(): AttemptStorage | undefined { + try { + return globalThis.localStorage + } catch { + return undefined + } +} + +function isAttempt(value: unknown): value is SignAttempt { + if (typeof value !== 'object' || value === null) return false + const a = value as Partial + return ( + typeof a.chain === 'string' && + typeof a.account === 'string' && + typeof a.tokenIn === 'string' && + typeof a.amount === 'string' && + typeof a.at === 'number' && + (a.hash === undefined || typeof a.hash === 'string') + ) +} + +function readAttempts(storage: AttemptStorage | undefined, now: number): SignAttempt[] { + try { + const saved: unknown = JSON.parse(storage?.getItem(ATTEMPTS_KEY) ?? '[]') + if (!Array.isArray(saved)) return [] + return saved.filter(isAttempt).filter((a) => now - a.at <= ATTEMPT_WINDOW_MS) + } catch { + return [] + } +} + +function writeAttempts(storage: AttemptStorage | undefined, attempts: SignAttempt[]): void { + try { + storage?.setItem(ATTEMPTS_KEY, JSON.stringify(attempts)) + } catch { + // The warning is an extra, and signing goes on without it. + } +} + +/** Called before the wallet opens. */ +export function recordAttempt(storage: AttemptStorage | undefined, attempt: SignAttempt): void { + writeAttempts(storage, [...readAttempts(storage, attempt.at), attempt]) +} + +/** Whether two records are one attempt. A hash may have been added since. */ +function sameAttempt(a: SignAttempt, b: SignAttempt): boolean { + return ( + a.at === b.at && + a.chain === b.chain && + a.account === b.account && + a.tokenIn === b.tokenIn && + a.amount === b.amount + ) +} + +function forgetAttempt(storage: AttemptStorage | undefined, attempt: SignAttempt): void { + const kept = readAttempts(storage, Date.now()).filter((a) => !sameAttempt(a, attempt)) + writeAttempts(storage, kept) +} + +function addAttemptHash( + storage: AttemptStorage | undefined, + attempt: SignAttempt, + hash: string +): void { + const attempts = readAttempts(storage, Date.now()) + writeAttempts( + storage, + attempts.map((a) => (sameAttempt(a, attempt) ? { ...a, hash } : a)) + ) +} + +function walletHash(signed: SignedPayload): string | undefined { + if (signed.family === 'evm') return signed.hash + if (signed.family === 'near') return signed.hashes[signed.hashes.length - 1] + return undefined +} + +/** + * What the wallet's answer says about the attempt. No step means no wallet here could sign, and a + * declined or failed signed-bytes wallet never sent anything. A wallet that sends for itself and + * gave no confirmation proves nothing, so that attempt stays. + */ +export function noteWalletAnswer( + storage: AttemptStorage | undefined, + attempt: SignAttempt, + step: AfterSigning | undefined +): void { + if (step === undefined || step.next === 'review' || step.next === 'failed') { + forgetAttempt(storage, attempt) + return + } + if (step.next !== 'submit') return + const hash = walletHash(step.signed) + if (hash !== undefined) addAttemptHash(storage, attempt, hash) +} + +/** + * What the server's answer says about the attempt. An answer lacks `pending` only if it proves + * nothing moved, a clean refusal or a proven revert, on every chain. So this reads the answer + * itself, where `afterSubmit` keeps a wallet that sends for itself latched regardless. A success + * stays too, since typing it again would pay twice. + */ +export function noteSubmitAnswer( + storage: AttemptStorage | undefined, + attempt: SignAttempt, + result: unknown +): void { + const body = (typeof result === 'object' && result !== null ? result : {}) as { + ok?: unknown + hash?: unknown + pending?: unknown + } + if (body.ok === false && body.pending !== true) { + forgetAttempt(storage, attempt) + return + } + if (body.ok === true && typeof body.hash === 'string') { + addAttemptHash(storage, attempt, body.hash) + } +} + +/** The newest attempt at this same action inside the window, if there is one. */ +export function earlierAttempt( + storage: AttemptStorage | undefined, + asked: Pick, + now: number = Date.now() +): SignAttempt | undefined { + const matches = readAttempts(storage, now).filter( + (a) => + a.chain === asked.chain && + accountMismatch(a.account, asked.account) === undefined && + a.tokenIn.toUpperCase() === asked.tokenIn.toUpperCase() && + Number(a.amount) === Number(asked.amount) + ) + return matches[matches.length - 1] +} + +export interface AttemptNotice { + text: string + hash?: string + explorerUrl?: string +} + +function attemptExplorerUrl(chain: string, hash: string): string | undefined { + // Read back from storage, so it can only ever be a path on the explorer. + const safe = encodeURIComponent(hash) + if (chain === 'monad') return evmExplorerUrl('monad', safe) + return isCashoutChain(chain) ? depositExplorerUrl(chain, safe) : undefined +} + +/** What the card says about an earlier attempt. */ +export function attemptNotice(attempt: SignAttempt, now: number = Date.now()): AttemptNotice { + const minutes = Math.max(1, Math.floor((now - attempt.at) / 60_000)) + const text = `You started this ${minutes} minute${minutes === 1 ? '' : 's'} ago, check your wallet first.` + if (attempt.hash === undefined) return { text } + const explorerUrl = attemptExplorerUrl(attempt.chain, attempt.hash) + return { + text, + hash: attempt.hash, + ...(explorerUrl !== undefined ? { explorerUrl } : {}), + } +} diff --git a/apps/dapp/lib/chains/solana/jupiter-lend.ts b/apps/dapp/lib/chains/solana/jupiter-lend.ts index 231afc39..c0e96428 100644 --- a/apps/dapp/lib/chains/solana/jupiter-lend.ts +++ b/apps/dapp/lib/chains/solana/jupiter-lend.ts @@ -10,11 +10,14 @@ import { z } from 'zod' import { base64FromBytes, bytesFromBase64 } from '../base64' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, + type VenueQuote, } from '../types' import { assertDepositAmount, @@ -152,7 +155,10 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV : { basePercent, rewardPercent } } - async function prepare(request: ChainActionRequest, account: string): Promise { + function checkedDeposit( + request: ChainActionRequest, + account: string + ): { token: ChainToken; amount: string } { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) if (request.kind !== 'supply' || token === undefined || !SUPPORTED.has(token.symbol)) { @@ -160,7 +166,16 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV `Jupiter Lend here takes USDC or USDT deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Jupiter Lend".` ) } - const amount = toBaseUnits(request.amount, token.decimals) + return { token, amount: toBaseUnits(request.amount, token.decimals) } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { token } = checkedDeposit(request, account) + return quotedApy(await readApy(token.id)) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount } = checkedDeposit(request, account) const apy = await readApy(token.id) const { status, body } = await venueFetch(`${JUPITER_API}/lend/v1/earn/deposit`, { @@ -197,6 +212,7 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV id: 'jupiter-lend', name: 'Jupiter Lend Earn', kinds: ['supply'], + quote, prepare, assertSafe: solanaAssertSafe(JUPITER_LEND_POLICY), } diff --git a/apps/dapp/lib/chains/solana/jupiter-swap.ts b/apps/dapp/lib/chains/solana/jupiter-swap.ts index a7dc9bd4..eed0469f 100644 --- a/apps/dapp/lib/chains/solana/jupiter-swap.ts +++ b/apps/dapp/lib/chains/solana/jupiter-swap.ts @@ -11,6 +11,7 @@ import { type PreparedAction, type SubmitInput, type SubmitResult, + type VenueQuote, } from '../types' import { assertWrapAtMost, solanaAssertSafe } from './assert-safe' import { JUPITER_API, jupiterHeaders, venueFetch } from './http' @@ -29,8 +30,11 @@ const REFERRAL_FEE_BPS = 50 * and OKX land through programs not confirmable from their docs. See programs.ts. */ const EXCLUDED_ROUTERS = 'jupiterz,dflow,okx' -/** `/execute` waits for the transaction to land, so it gets longer than other calls. */ -const EXECUTE_TIMEOUT_MS = 70_000 +/** + * `/execute` waits for the transaction to land, so it gets longer than other calls, but stops + * short of the submit route's 60 seconds so that an answer can still be given. + */ +const EXECUTE_TIMEOUT_MS = 50_000 const NEED_SOL_FOR_FEES = 'This wallet needs at least 0.01 SOL to pay its own network fees before I can swap. Add a little SOL and try again.' @@ -55,8 +59,8 @@ const Order = z.object({ }) const Executed = z.object({ - status: z.string(), - signature: z.string().optional(), + status: z.string().optional(), + signature: z.string().min(1).optional(), code: z.number().optional(), }) @@ -76,17 +80,29 @@ function explainOrderError(code: number | undefined, tokenIn: ChainToken): strin return 'Jupiter could not build this swap. Nothing was signed or sent. Check that you hold the token and a little SOL for fees, then try again.' } -function explainExecuteFailure(code: number | undefined): string { +/** + * The sentence for a code that proves the swap did not land, or nothing. -1000 and -2000 + * only say "Failed to land", and Jupiter's docs treat the blockhash's last valid height as + * the real expiry, so those leave open that it still lands and don't get a sentence here. + */ +function explainCleanFailure(code: number | undefined): string | undefined { if (code === -1 || code === -1004 || code === -1005 || code === -2003) { return 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.' } - if (code === -1000 || code === -2000) { - return 'This swap did not land on the network, so nothing moved. Get a fresh quote and try again.' - } if (code === -2 || code === -3 || code === -1002 || code === -1003 || code === -2002) { return 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.' } - return 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.' + return undefined +} + +/** The swap may have landed, so this must not read as a failure that invites a retry. */ +function unconfirmedSwap(signature: string | undefined): SubmitResult { + const where = signature !== undefined ? explorerUrl(signature) : 'your wallet’s activity' + return { + ok: false, + pending: true, + error: `Jupiter hasn’t confirmed this swap, and it may still have gone through, so don’t send it again. Check ${where} first, and get a fresh quote only if nothing changed.`, + } } function shortAmount(amount: string): string { @@ -114,7 +130,11 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue const env = options.env ?? process.env const assertSafe = solanaAssertSafe(JUPITER_SWAP_POLICY) - async function prepare(request: ChainActionRequest, account: string): Promise { + /** + * The order call and every check on what comes back. The race and Review both go through + * it, so a quote the agents can pick is one Review would build. + */ + async function checkedOrder(request: ChainActionRequest, account: string) { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') if (request.kind !== 'swap') { throw new ChainActionRefused(`Jupiter swaps tokens. It can't ${request.kind} here.`) @@ -213,6 +233,25 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue if (tokenIn.id === WRAPPED_SOL) assertWrapAtMost(order.transaction, BigInt(amount)) + const { transaction, requestId } = order + return { order, transaction, requestId, tokenIn, tokenOut, minimum, referral } + } + + /** What the swap would give back. The transaction is left here, since nothing signs it. */ + async function quote(request: ChainActionRequest, account: string): Promise { + const { order, tokenOut } = await checkedOrder(request, account) + return { + receive: { + amount: fromBaseUnits(order.outAmount, tokenOut.decimals), + symbol: tokenOut.symbol, + }, + } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { order, transaction, requestId, tokenIn, tokenOut, minimum, referral } = + await checkedOrder(request, account) + const received = fromBaseUnits(order.outAmount, tokenOut.decimals) const notes = [ `The price can move before this lands. You get at least ${minimum} ${tokenOut.symbol} or the swap fails, and network fees still apply.`, @@ -237,8 +276,8 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue pay: { amount: request.amount, symbol: tokenIn.symbol }, receive: { amount: received, symbol: tokenOut.symbol, approximate: true }, notes, - signing: { family: 'solana', transaction: order.transaction }, - context: { requestId: order.requestId }, + signing: { family: 'solana', transaction }, + context: { requestId }, } } @@ -255,43 +294,51 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue } } const { signedTransaction } = input.signed - if (feePayerSignature(signedTransaction) === undefined) { + const sentSignature = feePayerSignature(signedTransaction) + if (sentSignature === undefined) { return { ok: false, error: 'Your wallet returned this unsigned, so nothing was sent. Sign it again.', } } - const response = await fetch(`${JUPITER_API}/swap/v2/execute`, { - method: 'POST', - headers: { 'content-type': 'application/json', ...jupiterHeaders(env) }, - body: JSON.stringify({ signedTransaction, requestId }), - signal: AbortSignal.timeout(EXECUTE_TIMEOUT_MS), - }) - // A server failure or timeout leaves the outcome unknown. Throwing sends it to the - // route, which tells the user to check their wallet before trying again. + let response: Response + try { + response = await fetch(`${JUPITER_API}/swap/v2/execute`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...jupiterHeaders(env) }, + body: JSON.stringify({ signedTransaction, requestId }), + signal: AbortSignal.timeout(EXECUTE_TIMEOUT_MS), + }) + } catch { + // A timeout or dropped connection may have come after Jupiter broadcast it. + return unconfirmedSwap(sentSignature) + } + // A server failure leaves the outcome unknown. Throwing sends it to the route, which + // tells the user to check their wallet before trying again. if (response.status >= 500 || response.status === 429) { throw new Error(`Jupiter /execute answered ${response.status}`) } const parsed = Executed.safeParse(await response.json().catch(() => undefined)) - if (!response.ok || !parsed.success) { - return { - ok: false, - error: - 'Jupiter would not take this swap, so nothing was sent. Get a fresh quote and try again.', - } - } + // A body that cannot be read says nothing about whether the swap landed. + if (!parsed.success) return unconfirmedSwap(undefined) const { status, signature, code } = parsed.data - if (status === 'Success' && signature !== undefined) { + if (response.ok && status === 'Success' && signature !== undefined) { return { ok: true, hash: signature, explorerUrl: explorerUrl(signature) } } - return { ok: false, error: explainExecuteFailure(code) } + // /execute can answer long after broadcast and documents no non-2xx statuses, so the + // status alone proves nothing. Only a failure carrying a known code does, and an error + // body may not repeat the status field. + const failed = status === 'Failed' || (!response.ok && status === undefined) + const refusal = failed ? explainCleanFailure(code) : undefined + return refusal !== undefined ? { ok: false, error: refusal } : unconfirmedSwap(signature) } return { id: 'jupiter', name: 'Jupiter', kinds: ['swap'], + quote, prepare, assertSafe, submit, diff --git a/apps/dapp/lib/chains/solana/kamino.ts b/apps/dapp/lib/chains/solana/kamino.ts index 825ff531..140797e1 100644 --- a/apps/dapp/lib/chains/solana/kamino.ts +++ b/apps/dapp/lib/chains/solana/kamino.ts @@ -3,11 +3,14 @@ import { z } from 'zod' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, + type VenueQuote, } from '../types' import { assertDepositAmount, solanaAssertSafe } from './assert-safe' import { venueFetch } from './http' @@ -120,7 +123,11 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { return rewardPercent === 0 ? { basePercent } : { basePercent, rewardPercent } } - async function prepare(request: ChainActionRequest, account: string): Promise { + /** The vault a deposit would use, after every check that needs no transaction. */ + async function checkedDeposit( + request: ChainActionRequest, + account: string + ): Promise<{ token: ChainToken; amount: string; vault: Vault }> { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) if (request.kind !== 'supply' || token === undefined || token.symbol !== USDC) { @@ -142,7 +149,16 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { `This vault takes at least ${fromBaseUnits(minimum, token.decimals)} USDC. Try a larger amount.` ) } + return { token, amount, vault } + } + async function quote(request: ChainActionRequest, account: string): Promise { + const { vault } = await checkedDeposit(request, account) + return quotedApy(await readApy(vault.address)) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount, vault } = await checkedDeposit(request, account) const apy = await readApy(vault.address) const { status, body } = await venueFetch(`${KAMINO_API}/ktx/kvault/deposit`, { label: 'Kamino', @@ -178,6 +194,7 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { id: 'kamino', name: 'Kamino Earn', kinds: ['supply'], + quote, prepare, assertSafe: solanaAssertSafe(KAMINO_POLICY), } diff --git a/apps/dapp/lib/chains/solana/lulo.ts b/apps/dapp/lib/chains/solana/lulo.ts index e15c42d0..9198ea62 100644 --- a/apps/dapp/lib/chains/solana/lulo.ts +++ b/apps/dapp/lib/chains/solana/lulo.ts @@ -3,11 +3,14 @@ import { z } from 'zod' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, + type VenueQuote, } from '../types' import { assertDepositAmount, solanaAssertSafe } from './assert-safe' import { venueFetch } from './http' @@ -48,7 +51,10 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { : undefined } - async function prepare(request: ChainActionRequest, account: string): Promise { + function checkedDeposit( + request: ChainActionRequest, + account: string + ): { token: ChainToken; amount: string } { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) if (request.kind !== 'supply' || token === undefined || token.symbol !== 'USDC') { @@ -56,7 +62,16 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { `Lulo here takes USDC deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Lulo".` ) } - const amount = toBaseUnits(request.amount, token.decimals) + return { token, amount: toBaseUnits(request.amount, token.decimals) } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + checkedDeposit(request, account) + return quotedApy(await readApy()) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount } = checkedDeposit(request, account) const apy = await readApy() const { status, body } = await venueFetch(`${LULO_API}/v1/generate.transactions.deposit`, { @@ -102,6 +117,7 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { id: 'lulo', name: 'Lulo Protected', kinds: ['supply'], + quote, prepare, assertSafe: solanaAssertSafe(LULO_POLICY), } diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts index 5b87810b..e8fff1ac 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -1,3 +1,4 @@ +import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule, ChainVenue } from '../types' import { createJupiterLendVenue } from './jupiter-lend' import { createJupiterSwapVenue } from './jupiter-swap' @@ -9,12 +10,16 @@ import { SOLANA_TOKENS } from './tokens' const priceUsd = createPriceSource() +/** Inside the chain submit route's 60 seconds, leaving room to answer. Weave's route has its own. */ +const SUBMIT_DEADLINE_MS = 50_000 + export function buildSolanaVenues( env: Record = process.env ): ChainVenue[] { const luloKey = env['LULO_API_KEY']?.trim() return [ createJupiterSwapVenue({ priceUsd, env }), + ...(canIssueOneClick(env) ? [oneClickOriginVenue('solana')] : []), createJupiterLendVenue({ env }), createKaminoVenue(), ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []), @@ -27,5 +32,5 @@ export const solanaModule: ChainModule = { venues: buildSolanaVenues(), defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend' }, priceUsd, - submit: createSolanaSubmit(), + submit: createSolanaSubmit({ deadlineMs: SUBMIT_DEADLINE_MS }), } diff --git a/apps/dapp/lib/chains/solana/submit.ts b/apps/dapp/lib/chains/solana/submit.ts index aa2fa05c..84be4074 100644 --- a/apps/dapp/lib/chains/solana/submit.ts +++ b/apps/dapp/lib/chains/solana/submit.ts @@ -36,33 +36,66 @@ const Statuses = z.object({ ), }) +type Status = NonNullable['value'][number]> + const BlockhashValid = z.object({ value: z.boolean() }) const EXPIRED = 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.' -/** What the network said, in a sentence. Raw logs stay out of what the user reads. */ -function explainSendError(error: z.infer): string { +/** + * What the network said, in a sentence, when its answer proves nothing was sent. A node + * forwards a transaction only after preflight passes, so -32002 and the error names only + * preflight reports are that proof. Other errors, and any wording outside -32002, could come + * from a gateway that already passed the transaction on, so they return nothing. + * Raw logs stay out of what the user reads. + */ +function explainSendError(error: z.infer): string | undefined { const err = error.data?.err const logs = (error.data?.logs ?? []).join(' ').toLowerCase() const message = (error.message ?? '').toLowerCase() - if (err === 'BlockhashNotFound' || message.includes('blockhash not found')) return EXPIRED + const preflight = error.code === -32002 + // Preflight fails this way because the same transaction is already in a block. + if (err === 'AlreadyProcessed' || message.includes('already been processed')) return undefined + if (err === 'BlockhashNotFound' || (preflight && message.includes('blockhash not found'))) { + return EXPIRED + } if (err === 'AccountNotFound' || err === 'InsufficientFundsForFee') { return 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.' } + if (!preflight) return undefined if (logs.includes('insufficient funds') || logs.includes('insufficient lamports')) { return 'This wallet’s balance is too low for this transaction, so nothing was sent. Check your balance, including a little SOL for fees, and try again.' } - if (error.code === -32002 || message.includes('simulation')) { - return 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.' + return 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.' +} + +/** + * The outcome a status settles, or nothing. A processed block can sit on a fork that + * gets dropped, so neither its success nor its failure is final. + */ +function settledBy(status: Status, hash: string): SubmitResult | undefined { + if (status.confirmationStatus !== 'confirmed' && status.confirmationStatus !== 'finalized') { + return undefined + } + if (status.err !== null && status.err !== undefined) { + return { + ok: false, + error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: ${explorerUrl(hash)}`, + } } - return 'The network would not accept this transaction, so nothing was sent. Build it again and retry in a moment.' + return { ok: true, hash, explorerUrl: explorerUrl(hash) } } export interface SolanaSubmitOptions { env?: Record pollMs?: number maxPolls?: number + /** + * How long a submit may wait in all, for a route the platform would otherwise stop part way + * with no answer. Time up means pending, because the transaction may still land. + */ + deadlineMs?: number } /** @@ -74,18 +107,38 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { const env = options.env ?? process.env const pollMs = options.pollMs ?? POLL_MS const maxPolls = options.maxPolls ?? MAX_POLLS + const { deadlineMs } = options - async function rpc(method: string, params: unknown[]): Promise> { + async function rpc( + method: string, + params: unknown[], + timeoutMs = RPC_TIMEOUT_MS + ): Promise> { const url = env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(RPC_TIMEOUT_MS), + signal: AbortSignal.timeout(timeoutMs), }) + // A node answers JSON-RPC errors with a 200. Any other status came from something + // in between, so its body can't prove what the node did. + if (!response.ok) throw new Error(`The RPC answered ${response.status}`) return RpcReply.parse(await response.json()) } + /** A status, `null` if the node answered and hasn't seen it, `undefined` if it didn't answer. */ + async function readStatus( + hash: string, + timeoutMs: number, + config?: object + ): Promise { + const params = config !== undefined ? [[hash], config] : [[hash]] + const reply = await rpc('getSignatureStatuses', params, timeoutMs).catch(() => undefined) + const statuses = Statuses.safeParse(reply?.result) + return statuses.success ? statuses.data.value[0] : undefined + } + return async (input: SubmitInput): Promise => { if (input.signed.family !== 'solana') { return { ok: false, error: 'That is not a Solana transaction.' } @@ -99,38 +152,56 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { } } + const startedAt = Date.now() + const timeLeft = (): number => + deadlineMs === undefined ? Number.POSITIVE_INFINITY : startedAt + deadlineMs - Date.now() + const notConfirmed: SubmitResult = { + ok: false, + pending: true, + error: `This was sent, but not confirmed yet. Check ${explorerUrl(hash)} before trying again, in case it goes through.`, + } + const sent = await rpc('sendTransaction', [ signedTransaction, { encoding: 'base64', preflightCommitment: 'confirmed' }, ]) - if (sent.error !== undefined) return { ok: false, error: explainSendError(sent.error) } + if (sent.error !== undefined) { + const refusal = explainSendError(sent.error) + if (refusal !== undefined) return { ok: false, error: refusal } + return { + ok: false, + pending: true, + error: `The network’s answer doesn’t show whether this went out, so it may still go through. Don’t send it again until you’ve checked ${explorerUrl(hash)}.`, + } + } + // A read that went unanswered may have missed the transaction landing, and one that + // saw it means it reached a block. Either way, later silence no longer proves anything. + let mayHaveLanded = false for (let poll = 0; poll < maxPolls; poll += 1) { + if (timeLeft() <= 0) return notConfirmed await new Promise((resolve) => setTimeout(resolve, pollMs)) - const reply = await rpc('getSignatureStatuses', [[hash]]).catch(() => undefined) - const statuses = Statuses.safeParse(reply?.result) - const status = statuses.success ? statuses.data.value[0] : undefined - if (status === undefined || status === null) continue - if (status.err !== null && status.err !== undefined) { - return { - ok: false, - error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: ${explorerUrl(hash)}`, - } - } - if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') { - return { ok: true, hash, explorerUrl: explorerUrl(hash) } - } + const status = await readStatus(hash, Math.max(1, Math.min(RPC_TIMEOUT_MS, timeLeft()))) + if (status === null) continue + mayHaveLanded = true + const settled = status !== undefined ? settledBy(status, hash) : undefined + if (settled !== undefined) return settled } + if (timeLeft() <= 0) return notConfirmed const { blockhash } = decodeSolanaTransaction(signedTransaction) const validity = await rpc('isBlockhashValid', [blockhash, { commitment: 'confirmed' }]).catch( () => undefined ) const valid = BlockhashValid.safeParse(validity?.result) - if (valid.success && !valid.data.value) return { ok: false, error: EXPIRED } - return { - ok: false, - error: `This was sent, but not confirmed yet. Check ${explorerUrl(hash)} before trying again, in case it goes through.`, + if (valid.success && !valid.data.value) { + // An expired blockhash only rules out the future, so this read comes after it, to + // catch a transaction that landed before the blockhash ran out. + const last = await readStatus(hash, RPC_TIMEOUT_MS, { searchTransactionHistory: true }) + if (last === null && !mayHaveLanded) return { ok: false, error: EXPIRED } + const settled = last !== null && last !== undefined ? settledBy(last, hash) : undefined + if (settled !== undefined) return settled } + return notConfirmed } } diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts index 75d5ce00..82a45394 100644 --- a/apps/dapp/lib/chains/solana/venues.ts +++ b/apps/dapp/lib/chains/solana/venues.ts @@ -18,6 +18,16 @@ export const solanaVenues: Venue[] = [ capability: 'Swaps USDC, USDT, SOL, JitoSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.', }, + { + // Listed, as Lulo is: whether this runs depends on a secret this client-safe file can't read. + id: '1click-solana', + family: 'solana', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Solana'], + bestFor: 'A second swap price from competing market makers, spending USDC or USDT', + url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', + }, { id: 'jupiter-lend', family: 'solana', diff --git a/apps/dapp/lib/chains/sui/cetus.ts b/apps/dapp/lib/chains/sui/cetus.ts index f83bc925..07d7a498 100644 --- a/apps/dapp/lib/chains/sui/cetus.ts +++ b/apps/dapp/lib/chains/sui/cetus.ts @@ -9,6 +9,7 @@ import { type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { cetusOverlay, judgeRoute, SLIPPAGE_BPS } from './cetus-rules' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' @@ -35,7 +36,8 @@ function notes(tokens: string[], slippagePercent: number, feeBps: number | undef ] } -async function prepare(request: ChainActionRequest, account: string): Promise { +/** The best route and every check on it. The race and Review both go through this. */ +async function bestRoute(request: ChainActionRequest, account: string) { assertSuiAccount(account) const tokenIn = tokenBySymbol(SUI_TOKENS, request.tokenIn) const tokenOut = tokenBySymbol(SUI_TOKENS, request.tokenOut ?? '') @@ -87,6 +89,20 @@ async function prepare(request: ChainActionRequest, account: string): Promise { + const { tokenOut, amountOut } = await bestRoute(request, account) + return { + receive: { amount: displayAmount(amountOut, tokenOut.decimals), symbol: tokenOut.symbol }, + } +} + +async function prepare(request: ChainActionRequest, account: string): Promise { + const { tokenIn, tokenOut, client, overlay, aggregator, slippage, route, amountOut } = + await bestRoute(request, account) const tx = new Transaction() tx.setSender(account) @@ -114,6 +130,7 @@ export const cetusVenue: ChainVenue = { id: 'cetus', name: VENUE_NAME, kinds: ['swap'], + quote, prepare, assertSafe: (payload: SigningPayload, account: string) => assertPtbSafe(payload, account, CETUS_POLICY), diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts index 2595e9ca..a1d692f1 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,4 +1,5 @@ -import type { ChainModule } from '../types' +import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' +import type { ChainModule, ChainVenue } from '../types' import { cetusVenue } from './cetus' import { naviVenue } from './navi' import { priceFromLlama } from './price' @@ -6,10 +7,21 @@ import { springsuiVenue } from './springsui' import { submitSui } from './submit' import { SUI_TOKENS } from './tokens' +export function buildSuiVenues( + env: Record = process.env +): ChainVenue[] { + return [ + cetusVenue, + ...(canIssueOneClick(env) ? [oneClickOriginVenue('sui')] : []), + naviVenue, + springsuiVenue, + ] +} + export const suiModule: ChainModule = { chain: 'sui', tokens: SUI_TOKENS, - venues: [cetusVenue, naviVenue, springsuiVenue], + venues: buildSuiVenues(), defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui' }, priceUsd: (token) => priceFromLlama(token), submit: (input) => submitSui(input), diff --git a/apps/dapp/lib/chains/sui/navi.ts b/apps/dapp/lib/chains/sui/navi.ts index 916c79fa..868ffcb4 100644 --- a/apps/dapp/lib/chains/sui/navi.ts +++ b/apps/dapp/lib/chains/sui/navi.ts @@ -4,12 +4,14 @@ import { depositCoinPTB, getPools } from '@naviprotocol/lending' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { naviApy, poolNotes } from './navi-rules' @@ -23,7 +25,8 @@ const POOL_CACHE_MS = 30_000 /** NAVI reports pool amounts with nine decimals, whatever the token's own. */ const POOL_AMOUNT_SCALE = 1e9 -async function prepare(request: ChainActionRequest, account: string): Promise { +/** The pool a deposit would use, after every check that needs no transaction. */ +async function checkedPool(request: ChainActionRequest, account: string) { assertSuiAccount(account) const token = tokenBySymbol(SUI_TOKENS, request.tokenIn) if (token?.symbol !== 'USDC') { @@ -49,6 +52,16 @@ async function prepare(request: ChainActionRequest, account: string): Promise { + const { pool } = await checkedPool(request, account) + return quotedApy(naviApy(pool)) +} + +async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount, pool } = await checkedPool(request, account) const tx = new Transaction() tx.setSender(account) @@ -80,6 +93,7 @@ export const naviVenue: ChainVenue = { id: 'navi', name: 'NAVI', kinds: ['supply'], + quote, prepare, assertSafe: (payload: SigningPayload, account: string) => assertPtbSafe(payload, account, NAVI_POLICY), diff --git a/apps/dapp/lib/chains/sui/springsui.ts b/apps/dapp/lib/chains/sui/springsui.ts index 9c4bfce9..da172d22 100644 --- a/apps/dapp/lib/chains/sui/springsui.ts +++ b/apps/dapp/lib/chains/sui/springsui.ts @@ -8,6 +8,7 @@ import { type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { @@ -26,7 +27,8 @@ const SUI_DECIMALS = 9 const SSUI_DECIMALS = 9 const DRY_RUN_TIMEOUT_MS = 15_000 -async function prepare(request: ChainActionRequest, account: string): Promise { +/** Builds the stake and dry-runs it, which moves nothing, to read what it would mint. */ +async function checkedStake(request: ChainActionRequest, account: string) { assertSuiAccount(account) if (request.tokenIn.trim().toUpperCase() !== 'SUI') { throw new ChainActionRefused( @@ -69,7 +71,17 @@ async function prepare(request: ChainActionRequest, account: string): Promise { + const { receive } = await checkedStake(request, account) + return { receive: { amount: receive, symbol: 'sSUI' } } +} + +async function prepare(request: ChainActionRequest, account: string): Promise { + const { transaction, receive } = await checkedStake(request, account) return { venueId: 'springsui', venueName: 'SpringSui', @@ -90,6 +102,7 @@ export const springsuiVenue: ChainVenue = { id: 'springsui', name: 'SpringSui', kinds: ['stake'], + quote, prepare, assertSafe: (payload: SigningPayload, account: string) => assertPtbSafe(payload, account, SPRINGSUI_POLICY), diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts index 3d402ea5..0982fc41 100644 --- a/apps/dapp/lib/chains/sui/venues.ts +++ b/apps/dapp/lib/chains/sui/venues.ts @@ -29,6 +29,14 @@ export const suiVenues: Venue[] = [ integration: 'executes', capability: 'Swaps SUI, USDC, USDsui and USDT', }, + // Listed, as Lulo is on Solana: whether this runs depends on a secret this client-safe file can't read. + listed( + '1click-sui', + 'NEAR Intents 1Click', + 'swap', + 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', + 'A second swap price from competing market makers, spending USDC or USDT' + ), { id: 'navi', family: 'sui', diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 26f8d782..081f448a 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -6,7 +6,7 @@ import type { ChainSlug } from '@intent/types' * routes, the hook and the chat card know nothing about any chain in particular. */ -export type ChainWithActions = Extract +export type ChainWithActions = Extract export type ChainActionKind = 'swap' | 'supply' | 'stake' @@ -29,12 +29,12 @@ export interface NearTransaction { }[] } -/** What the wallet is asked to sign. Base64 for Solana and Sui. */ +/** What the wallet is asked to sign. Base64 for Solana and Sui. An EVM `value` is decimal base units. */ export type SigningPayload = | { family: 'solana'; transaction: string } | { family: 'sui'; transaction: string } | { family: 'near'; transactions: NearTransaction[] } - | { family: 'evm'; chainId: number; to: string; data: string; value: '0' } + | { family: 'evm'; chainId: number; to: string; data: string; value: string } | { family: 'stellar'; xdr: string; account: string } /** @@ -96,18 +96,44 @@ export interface ChainToken { /** Raised for a refusal the user should read, such as an unknown token or thin liquidity. */ export class ChainActionRefused extends Error {} +/** What a venue says it would give back, read without building anything to sign. */ +export interface VenueQuote { + receive?: { amount: string; symbol: string } + apyPercent?: number + /** Only where every route on the chain is valued by the same source, as on NEAR. */ + valueOutUsd?: number + /** + * What this route costs the wallet beyond what it delivers, as a route elsewhere would not. + * The race takes it off the route's value, at the chain's own price for this token. + */ + walletCost?: { amount: string; symbol: string } +} + export interface ChainVenue { id: string name: string kinds: readonly ChainActionKind[] + /** + * A price for the agent race. Must not create a deposit address or any other + * thing that costs money or leaves state behind: a race quotes every venue, + * and only the one the user presses Review on may be built. + */ + quote?: ((request: ChainActionRequest, account: string) => Promise) | undefined prepare: (request: ChainActionRequest, account: string) => Promise /** * Refuses a payload that does anything but what was asked: wrong signer, an * unexpected program, a transfer out. Runs on what the venue's API returned * before the user sees it, and again on signed bytes at submit, because the * venue's API and the browser are both outside what this server controls. + * + * `context` is what `prepare` issued, handed back by the browser. A venue that reads it + * must prove the server wrote it before believing any of it. */ - assertSafe: (payload: SigningPayload, account: string) => void + assertSafe: ( + payload: SigningPayload, + account: string, + context?: Record | undefined + ) => void /** Overrides the module's submit where a venue lands its own transactions. */ submit?: ((input: SubmitInput) => Promise) | undefined } @@ -121,6 +147,21 @@ export interface ChainModule { priceUsd: (token: ChainToken) => Promise /** Sends signed bytes, or records what a wallet already sent. */ submit: (input: SubmitInput) => Promise + /** A sentence for a symbol this chain doesn't list but a person may well type, by upper-case symbol. */ + tokenHints?: Record | undefined +} + +/** "an Avalanche", "a Monad". */ +export function withArticle(name: string): string { + return `${/^[AEIOU]/.test(name) ? 'an' : 'a'} ${name}` +} + +/** + * A venue's rate as the race reads it: base and reward together, since the agents compare + * one figure. No rate read means no rate here, never a zero or a guess. + */ +export function quotedApy(apy: PreparedAction['apy']): VenueQuote { + return apy === undefined ? {} : { apyPercent: apy.basePercent + (apy.rewardPercent ?? 0) } } export function tokenBySymbol( diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index df6dcd92..6f4b134b 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -1,3 +1,6 @@ +import { chainModule } from '../chains/registry' +import type { ChainActionKind } from '../chains/types' + /** * What Intent does, written down once. * @@ -5,10 +8,19 @@ * nothing else. A model left to describe a product from its own idea of one will * promise features it lacks, so every claim it may make about Intent is here, * and the prompt forbids the rest. + * + * Server only: it reads the chains' venues, and whether a chain has any can turn + * on a secret. Its one importer is the orchestrator, which only an API route runs. */ +const SWAP_RACED = + 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.' +const SWAP_RACED_OFF_STELLAR = + 'Swap: trade one asset for another at the market price. Agents compete to propose the best route; you review it and sign in your wallet.' +const SWAP_DIRECT = + 'Swap: trade one asset for another at the market price. You review it and sign in your wallet.' + const COMMON = [ - 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.', 'Accumulate: spread the buying of an asset over time instead of all at once.', 'Hedge or rebalance: move exposure from one asset into another.', 'Send: send an asset such as USDC or XLM to another account or to a name like deon.xlm. You review it and sign in your wallet.', @@ -22,8 +34,37 @@ const STELLAR_ONLY = [ 'Each trade is capped at $500 on mainnet.', ] +const NOTHING_RUNS = 'Nothing can be swapped, supplied or staked on this chain right now.' + +function bullets(lines: string[]): string { + return lines.map((l) => `- ${l}`).join('\n') +} + /** The facts about Intent that apply on a chain, one per line. */ export function capabilitySheet(chain: string): string { - const lines = chain === 'stellar' ? [...COMMON, ...STELLAR_ONLY] : COMMON - return lines.map((l) => `- ${l}`).join('\n') + if (chain === 'stellar') { + return bullets([SWAP_RACED, ...COMMON, ...STELLAR_ONLY]) + } + const venues = chainModule(chain)?.venues + if (venues === undefined) return `- ${SWAP_DIRECT}` + + // Off Stellar only what the chain's own venues can do is claimed: accumulate, + // hedge and send are Stellar's, and the chat reads only swap, supply and stake. + // A race needs a venue that can quote, so a kind with none is never promised one. + const offered = (kind: ChainActionKind): boolean => venues.some((v) => v.kinds.includes(kind)) + const raced = (kind: ChainActionKind): boolean => + venues.some((v) => v.kinds.includes(kind) && v.quote !== undefined) + const lines: string[] = [] + if (offered('swap')) lines.push(raced('swap') ? SWAP_RACED_OFF_STELLAR : SWAP_DIRECT) + if (offered('supply')) { + lines.push( + `Supply: put an asset you hold into a lending or earn venue to earn yield. ${raced('supply') ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` + ) + } + if (offered('stake')) { + lines.push( + `Stake: stake an asset for a staking token. ${raced('stake') ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` + ) + } + return bullets(lines.length > 0 ? lines : [NOTHING_RUNS]) } diff --git a/apps/dapp/lib/server/analytics.ts b/apps/dapp/lib/server/analytics.ts index 4f6ed76a..7e4aa127 100644 --- a/apps/dapp/lib/server/analytics.ts +++ b/apps/dapp/lib/server/analytics.ts @@ -95,6 +95,8 @@ export const ANALYTICS_DDL: readonly string[] = [ // free text from every model on every race rather than the one plan a // user's funds actually moved on. `ALTER TABLE agent_proposals ADD COLUMN IF NOT EXISTS reasoning TEXT`, + // Races from before other chains ran are all Stellar's, which the default says. + `ALTER TABLE agent_races ADD COLUMN IF NOT EXISTS chain TEXT NOT NULL DEFAULT 'stellar'`, ] export type ExecutionKind = @@ -110,6 +112,8 @@ export type ExecutionKind = | 'solana' | 'sui' | 'near' + | 'avalanche' + | 'monad' export interface ExecutionRecord { network: StellarNetworkName @@ -139,6 +143,13 @@ export interface SubmitLike { code?: string } +const OTHER_CHAINS: readonly ExecutionKind[] = ['solana', 'sui', 'near', 'avalanche', 'monad'] + +/** The chain an execution happened on, which is what a race it follows must have run on. */ +function raceChainOf(kind: ExecutionKind): string { + return OTHER_CHAINS.includes(kind) ? kind : 'stellar' +} + const CODE = /^[a-z][a-z0-9_]{0,39}$/ /** @@ -197,6 +208,8 @@ export interface RaceAttemptFailed { export interface RaceInput { id: string network: StellarNetworkName + /** The chain the race ran on; Stellar when omitted. */ + chain?: string startedAt: Date durationMs: number intent: { type: string; tokenIn: string; tokenOut: string; sizeUsd: number | undefined } @@ -209,6 +222,7 @@ export interface RaceInput { export interface RaceRecord { id: string network: StellarNetworkName + chain: string startedAt: Date intentType: string tokenIn: string @@ -245,6 +259,7 @@ export function raceRecordFrom(input: RaceInput): RaceRecord { return { id: input.id, network: input.network, + chain: input.chain ?? 'stellar', startedAt: input.startedAt, intentType: input.intent.type, tokenIn: input.intent.tokenIn, @@ -319,6 +334,8 @@ export interface HistoryQuery { export interface HistoryEntry { raceId: string + /** The chain the race ran on, since one EVM address can have raced on several. */ + chain: string startedAt: string tokenIn: string tokenOut: string @@ -352,6 +369,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, (SELECT id FROM agent_races WHERE id = $14::uuid AND network = $2 AND winner IS NOT NULL + AND chain = $15 AND started_at <= $9::timestamptz AND started_at > $9::timestamptz - interval '30 minutes')) ON CONFLICT DO NOTHING`, @@ -370,6 +388,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { r.flow?.amountIn ?? null, r.flow?.volumeUsd ?? null, r.raceId ?? null, + raceChainOf(r.kind), ] ) }, @@ -397,8 +416,8 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { await query( `INSERT INTO agent_races (id, network, started_at, intent_type, token_in, token_out, size_usd, - agents, answered, winner, unanimous, outcome, duration_ms) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + agents, answered, winner, unanimous, outcome, duration_ms, chain) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT DO NOTHING`, [ r.id, @@ -414,6 +433,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { r.unanimous, r.outcome, r.durationMs, + r.chain, ] ) for (const p of r.proposals) { @@ -453,6 +473,10 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { * there is nothing to explain to this wallet about a plan it never * acted on, winning or not. * + * An EVM address matches whatever its casing, since a wallet may report it checksummed + * on one call and lower-case on another; every other chain's address is exact, as + * Solana and Sui addresses are case-sensitive. + * * `address` scopes by equality on an indexed column — the same trust * model every other wallet-scoped read in this app uses (naira orders, * swap history): whoever knows the address can read what it did. That @@ -462,18 +486,21 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { const limit = Math.max(1, Math.min(100, pageSize)) const offset = Math.max(0, (Math.max(1, page) - 1) * limit) const { rows } = await query( - `SELECT r.id AS race_id, r.started_at, r.token_in, r.token_out, r.size_usd, + `SELECT r.id AS race_id, r.chain, r.started_at, r.token_in, r.token_out, r.size_usd, r.winner, p.model AS winning_model, p.reasoning, e.hash AS executed_hash FROM usage_executions e JOIN agent_races r ON r.id = e.race_id LEFT JOIN agent_proposals p ON p.race_id = r.id AND p.won - WHERE e.network = $1 AND e.account = $2 + WHERE e.network = $1 + AND (e.account = $2 + OR ($2 ~ '^0x[0-9a-fA-F]{40}$' AND lower(e.account) = lower($2))) ORDER BY r.started_at DESC LIMIT $3 OFFSET $4`, [network, account, limit + 1, offset] ) const entries = rows.slice(0, limit).map((row) => ({ raceId: String(row['race_id']), + chain: String(row['chain']), startedAt: new Date(row['started_at'] as string).toISOString(), tokenIn: String(row['token_in']), tokenOut: String(row['token_out']), diff --git a/apps/dapp/lib/venues.ts b/apps/dapp/lib/venues.ts index 6bba43be..a3165377 100644 --- a/apps/dapp/lib/venues.ts +++ b/apps/dapp/lib/venues.ts @@ -1,6 +1,7 @@ import { activeNetwork, type StellarNetworkName } from '@intent/config' -import type { Venue } from '@intent/types' +import type { ChainDescriptor, Venue } from '@intent/types' +import { evmVenues } from './chains/evm/venues' import { nearVenues } from './chains/near/venues' import { solanaVenues } from './chains/solana/venues' import { suiVenues } from './chains/sui/venues' @@ -10,6 +11,7 @@ export const venues: Venue[] = [ ...solanaVenues, ...suiVenues, ...nearVenues, + ...evmVenues, { id: 'cow-swap', family: 'evm' as const, @@ -323,6 +325,24 @@ export const venues: Venue[] = [ }, ] +const EVM_CHAINS_WITH_VENUES = ['Avalanche', 'Monad'] + +/** + * Whether a venue belongs on this chain's lists. Family decides, except that EVM chains share one: + * Avalanche and Monad list only the venues that name them, and a venue naming either one belongs + * to that chain alone. Arc has no venues of its own, so it keeps the rest of the family's list. + */ +export function venueOnChain( + venue: Pick, + chain: Pick +): boolean { + if (venue.family !== chain.family) return false + if (chain.family !== 'evm') return true + return EVM_CHAINS_WITH_VENUES.includes(chain.name) + ? venue.chains.includes(chain.name) + : !venue.chains.some((name) => EVM_CHAINS_WITH_VENUES.includes(name)) +} + /** * Which venues exist on which network. * diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index 38e89f9b..3ab144cc 100644 --- a/apps/dapp/lib/weave/cashout-view.ts +++ b/apps/dapp/lib/weave/cashout-view.ts @@ -196,7 +196,7 @@ export function cleanIdentifier(text: string): string { type SigningFamily = SigningPayload['family'] /** These wallets broadcast for themselves, so an error back may follow a transfer that went out. */ -function walletSends(family: SigningFamily): boolean { +export function walletSends(family: SigningFamily): boolean { return family === 'near' || family === 'evm' } diff --git a/apps/dapp/lib/weave/deposit/avalanche.ts b/apps/dapp/lib/weave/deposit/avalanche.ts index a44c7d13..afec5df7 100644 --- a/apps/dapp/lib/weave/deposit/avalanche.ts +++ b/apps/dapp/lib/weave/deposit/avalanche.ts @@ -6,7 +6,6 @@ import { http, isAddress, parseAbi, - parseEventLogs, zeroAddress, type Address, type Hash, @@ -16,6 +15,7 @@ import { } from 'viem' import { avalanche } from 'viem/chains' +import { erc20TransferPaid } from '../../chains/evm/submit' import { ChainActionRefused, toBaseUnits, @@ -87,8 +87,16 @@ function expectedUnits(amount: string): bigint { } } +/** This deposit moves USDC and nothing else, so an order naming another token must not reach it. */ +function assertUsdc(expectation: DepositExpectation): void { + if (expectation.token !== undefined && !same(expectation.token.id, USDC)) { + refuse('Avalanche deposits here move USDC and no other token.') + } +} + function assertSafe(payload: SigningPayload, expectation: DepositExpectation): void { if (payload.family !== 'evm') return refuse('That is not an Avalanche transaction.') + assertUsdc(expectation) if (expectation.memo !== undefined) refuse('Avalanche cannot carry the memo this order needs.') if (!isDepositAddress(expectation.depositAddress)) { refuse('This order’s deposit address cannot take a deposit.') @@ -138,16 +146,12 @@ async function readBalances( } function paidThisOrder(receipt: DepositReceipt, expectation: DepositExpectation): boolean { - if (!same(receipt.from, expectation.account)) return false - if (receipt.to === null || !same(receipt.to, USDC)) return false - const amount = BigInt(toBaseUnits(expectation.amount, USDC_DECIMALS)) - return parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs }).some( - (log) => - same(log.address, USDC) && - same(log.args.from, expectation.account) && - same(log.args.to, expectation.depositAddress) && - log.args.value === amount - ) + return erc20TransferPaid(receipt, { + token: USDC, + account: expectation.account, + depositAddress: expectation.depositAddress, + units: BigInt(toBaseUnits(expectation.amount, USDC_DECIMALS)), + }) } /** Takes the client as a function so nothing connects until a deposit is actually read. */ @@ -197,6 +201,11 @@ export function createAvalancheDeposit(client: () => AvalancheClient): DepositCh 'That is not an Avalanche transaction. Nothing was sent from here.' ) } + if (expectation.token !== undefined && !same(expectation.token.id, USDC)) { + throw new ChainActionRefused( + 'This deposit can only be confirmed as USDC. Nothing was sent from here.' + ) + } const { hash } = signed if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) { throw new ChainActionRefused( diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index f7a83e90..0ddf5480 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -64,14 +64,13 @@ const COMPUTE_UNIT_LIMIT = 100_000 /** About 0.00001 SOL at the limit above, enough to land without a live fee estimate. */ const COMPUTE_UNIT_PRICE = BigInt(100_000) -/** - * `createSolanaSubmit` has no flag for a send it can't confirm yet, only this wording. - * The tests run the real function, so a change to the wording fails them. - */ -const UNCONFIRMED_SEND = 'not confirmed yet' +function tokenOf(expectation: DepositExpectation): ChainToken { + return expectation.token ?? USDC +} -const NOT_ONE_TRANSFER = - 'This transaction asks the Token Program for something other than one USDC transfer.' +function notOneTransfer(token: ChainToken): string { + return `This transaction asks the Token Program for something other than one ${token.symbol} transfer.` +} const RpcAccount = z.object({ result: z.object({ value: z.object({ owner: z.string() }).nullable() }), @@ -97,13 +96,14 @@ function orderUnits(expectation: DepositExpectation): bigint { if (!isAddress(account)) refuse('That is not a Solana wallet address.') if (!isAddress(depositAddress)) refuse('This order’s deposit address is not a Solana address.') if (depositAddress === account) refuse('This order’s deposit address is your own wallet.') + const token = tokenOf(expectation) let units: bigint try { - units = BigInt(toBaseUnits(amount, USDC.decimals)) + units = BigInt(toBaseUnits(amount, token.decimals)) } catch (e) { return refuse(e instanceof Error ? e.message : 'That is not an amount.') } - if (units > MAX_U64) refuse('This order’s amount is too large for USDC.') + if (units > MAX_U64) refuse(`This order’s amount is too large for ${token.symbol}.`) return units } @@ -158,7 +158,7 @@ function checkAccountCreation( expectation.account, destination, expectation.depositAddress, - USDC.id, + tokenOf(expectation).id, SYSTEM_PROGRAM, TOKEN_PROGRAM, ] @@ -180,13 +180,14 @@ function checkTransfer( destination: string, units: bigint ): void { + const token = tokenOf(expectation) const [from, mint, to, owner, ...rest] = accountsOf(transaction, instruction) - if (rest.length > 0 || owner === undefined) refuse(NOT_ONE_TRANSFER) - if (mint !== USDC.id || instruction.data[9] !== USDC.decimals) { - refuse('This transfer is not USDC.') + if (rest.length > 0 || owner === undefined) refuse(notOneTransfer(token)) + if (mint !== token.id || instruction.data[9] !== token.decimals) { + refuse(`This transfer is not ${token.symbol}.`) } - if (from !== associatedTokenAccount(expectation.account, USDC.id)) { - refuse('This transfer does not come from your USDC account.') + if (from !== associatedTokenAccount(expectation.account, token.id)) { + refuse(`This transfer does not come from your ${token.symbol} account.`) } if (owner !== expectation.account) refuse('This transfer is not authorised by your wallet.') if (to !== destination) refuse('This transfer does not go to this order’s deposit address.') @@ -197,12 +198,13 @@ function checkTransfer( /** * Only this shape passes: compute budget settings, then at most one creation of the - * deposit's USDC account, then one TransferChecked. Lookup tables are refused outright + * deposit's token account, then one TransferChecked. Lookup tables are refused outright * because resolving one needs the network, and this check has to see every account. */ function assertSafe(payload: SigningPayload, expectation: DepositExpectation): void { if (payload.family !== 'solana') refuse('That is not a Solana transaction.') const units = orderUnits(expectation) + const token = tokenOf(expectation) const transaction = decode(payload.transaction) if (hasLookupTables(payload.transaction)) { refuse('This transaction uses a lookup table, which hides accounts from this check.') @@ -224,19 +226,19 @@ function assertSafe(payload: SigningPayload, expectation: DepositExpectation): v return 'create' case TOKEN_PROGRAM: if (instruction.data.length !== 10 || instruction.data[0] !== TRANSFER_CHECKED) { - refuse(NOT_ONE_TRANSFER) + refuse(notOneTransfer(token)) } return 'transfer' default: - return refuse('This transaction calls a program a USDC deposit does not need.') + return refuse(`This transaction calls a program a ${token.symbol} deposit does not need.`) } }) if (!/^(budget,)*(create,)?transfer$/.test(steps.join(','))) { - refuse('This transaction is not the one USDC transfer this order needs.') + refuse(`This transaction is not the one ${token.symbol} transfer this order needs.`) } checkComputeBudget(budget) - const destination = associatedTokenAccount(expectation.depositAddress, USDC.id) + const destination = associatedTokenAccount(expectation.depositAddress, token.id) const transfer = transaction.instructions[transaction.instructions.length - 1] as Instruction const creation = transaction.instructions[transaction.instructions.length - 2] if (steps[steps.length - 2] === 'create' && creation !== undefined) { @@ -288,14 +290,19 @@ async function usdcBalance(account: string): Promise { * Funds sent to a token account's own token account can't be moved by anyone, since a * token account has no key to sign with. A missing account is fine: it is a fresh wallet. */ -async function assertDepositIsNotTokenAccount(depositAddress: string): Promise { +async function assertDepositIsNotTokenAccount( + depositAddress: string, + token: ChainToken +): Promise { const reply = RpcAccount.safeParse( await rpc('getAccountInfo', [depositAddress, { encoding: 'base64', commitment: 'confirmed' }]) ) if (!reply.success) refuse('Solana’s network could not be read just now.') const owner = reply.data.result.value?.owner if (owner === TOKEN_PROGRAM || owner === TOKEN_2022_PROGRAM) { - refuse('This order’s deposit address is a token account, and USDC sent there would be stuck.') + refuse( + `This order’s deposit address is a token account, and ${token.symbol} sent there would be stuck.` + ) } } @@ -305,7 +312,8 @@ function compile( lifetime: { blockhash: string; lastValidBlockHeight: number } ): string { const { account, depositAddress } = expectation - const destination = associatedTokenAccount(depositAddress, USDC.id) + const token = tokenOf(expectation) + const destination = associatedTokenAccount(depositAddress, token.id) const message = pipe( createTransactionMessage({ version: 'legacy' }), (m) => setTransactionMessageFeePayer(account as Address, m), @@ -340,7 +348,7 @@ function compile( { address: account as Address, role: AccountRole.WRITABLE_SIGNER }, { address: destination as Address, role: AccountRole.WRITABLE }, { address: depositAddress as Address, role: AccountRole.READONLY }, - { address: USDC.id as Address, role: AccountRole.READONLY }, + { address: token.id as Address, role: AccountRole.READONLY }, { address: SYSTEM_PROGRAM as Address, role: AccountRole.READONLY }, { address: TOKEN_PROGRAM as Address, role: AccountRole.READONLY }, ], @@ -350,17 +358,17 @@ function compile( programAddress: TOKEN_PROGRAM as Address, accounts: [ { - address: associatedTokenAccount(account, USDC.id) as Address, + address: associatedTokenAccount(account, token.id) as Address, role: AccountRole.WRITABLE, }, - { address: USDC.id as Address, role: AccountRole.READONLY }, + { address: token.id as Address, role: AccountRole.READONLY }, { address: destination as Address, role: AccountRole.WRITABLE }, { address: account as Address, role: AccountRole.READONLY_SIGNER }, ], data: Uint8Array.from([ TRANSFER_CHECKED, ...getU64Encoder().encode(units), - USDC.decimals, + token.decimals, ]), }, ], @@ -372,7 +380,7 @@ function compile( async function build(expectation: DepositExpectation): Promise { const units = orderUnits(expectation) - await assertDepositIsNotTokenAccount(expectation.depositAddress) + await assertDepositIsNotTokenAccount(expectation.depositAddress, tokenOf(expectation)) const reply = RpcBlockhash.safeParse( await rpc('getLatestBlockhash', [{ commitment: 'confirmed' }]) ) @@ -386,11 +394,11 @@ async function build(expectation: DepositExpectation): Promise { return payload } -function pending(hash: string): SubmitResult { +function pending(hash: string, token: ChainToken): SubmitResult { return { ok: false, pending: true, - error: `Your USDC deposit was sent (${hash}) but isn't confirmed yet. Don't send it again until you've checked ${explorerUrl(hash)}.`, + error: `Your ${token.symbol} deposit was sent (${hash}) but isn't confirmed yet. Don't send it again until you've checked ${explorerUrl(hash)}.`, } } @@ -415,9 +423,9 @@ async function submit( result = await createSolanaSubmit()({ account: expectation.account, venueId: 'weave', signed }) } catch { // A connection that broke during the send says nothing about whether it landed. - return pending(hash) + return pending(hash, tokenOf(expectation)) } - return !result.ok && result.error.includes(UNCONFIRMED_SEND) ? pending(hash) : result + return !result.ok && result.pending === true ? pending(hash, tokenOf(expectation)) : result } export const solanaDeposit: DepositChain = { diff --git a/apps/dapp/lib/weave/deposit/sui.ts b/apps/dapp/lib/weave/deposit/sui.ts index e6ca031b..40f94a77 100644 --- a/apps/dapp/lib/weave/deposit/sui.ts +++ b/apps/dapp/lib/weave/deposit/sui.ts @@ -27,7 +27,14 @@ type TxCommand = TxData['commands'][number] type TxArgument = Extract['SplitCoins']['coin'] const USDC = tokenBySymbol(SUI_TOKENS, 'USDC') as ChainToken -const USDC_COIN = normalizeStructTag(`0x2::coin::Coin<${USDC.id}>`) + +function tokenOf(expectation: DepositExpectation): ChainToken { + return expectation.token ?? USDC +} + +function coinTypeOf(token: ChainToken): string { + return normalizeStructTag(`0x2::coin::Coin<${token.id}>`) +} const MAX_U64 = BigInt('18446744073709551615') const READ_TIMEOUT_MS = 10_000 @@ -49,13 +56,14 @@ function orderUnits(expectation: DepositExpectation): string { if (normalizeSuiAddress(depositAddress) === normalizeSuiAddress(account)) { refuse('This order’s deposit address is your own wallet.') } + const token = tokenOf(expectation) let units: string try { - units = toBaseUnits(amount, USDC.decimals) + units = toBaseUnits(amount, token.decimals) } catch (e) { return refuse(e instanceof Error ? e.message : 'That is not an amount.') } - if (BigInt(units) > MAX_U64) refuse('This order’s amount is too large for USDC.') + if (BigInt(units) > MAX_U64) refuse(`This order’s amount is too large for ${token.symbol}.`) return units } @@ -106,6 +114,7 @@ function isResultOf(argument: TxArgument | undefined, command: number): boolean */ function spentCoins(transaction: string, expectation: DepositExpectation): string[] { const units = orderUnits(expectation) + const { symbol } = tokenOf(expectation) const me = normalizeSuiAddress(expectation.account) const data = parse(transaction) @@ -125,14 +134,14 @@ function spentCoins(transaction: string, expectation: DepositExpectation): strin const transfer = data.commands[splitAt + 1] const merges = data.commands.slice(0, Math.max(splitAt, 0)) if (split?.$kind !== 'SplitCoins' || transfer?.$kind !== 'TransferObjects') { - return refuse('This transaction does more than the one USDC transfer this order needs.') + return refuse(`This transaction does more than the one ${symbol} transfer this order needs.`) } const coin = coinInput(data, split.SplitCoins.coin) const coins = new Set([coin]) for (const merge of merges) { if (merge.$kind !== 'MergeCoins') { - return refuse('This transaction does more than the one USDC transfer this order needs.') + return refuse(`This transaction does more than the one ${symbol} transfer this order needs.`) } if (coinInput(data, merge.MergeCoins.destination) !== coin) { refuse('This transaction merges coins into something other than the coin it pays from.') @@ -186,17 +195,23 @@ async function read(work: Promise): Promise { } } -async function coinsCovering(account: string, units: bigint): Promise { +async function coinsCovering( + account: string, + units: bigint, + expectation: DepositExpectation +): Promise { const { core } = suiClient() + const token = tokenOf(expectation) + const wanted = coinTypeOf(token) const picked: SuiClientTypes.Coin[] = [] let total = BigInt(0) let cursor: string | null = null for (let page = 0; page < MAX_COIN_PAGES; page += 1) { const reply: SuiClientTypes.ListCoinsResponse = await read( - core.listCoins({ owner: account, coinType: USDC.id, limit: COINS_PER_PAGE, cursor }) + core.listCoins({ owner: account, coinType: token.id, limit: COINS_PER_PAGE, cursor }) ) for (const coin of reply.objects) { - if (total < units && normalizeStructTag(coin.type) === USDC_COIN) { + if (total < units && normalizeStructTag(coin.type) === wanted) { picked.push(coin) total += BigInt(coin.balance) } @@ -206,19 +221,22 @@ async function coinsCovering(account: string, units: bigint): Promise= units) { + // Weave's orders name no token, and a person there is cashing out rather than swapping. + const action = expectation.token === undefined ? 'cash-out' : 'swap' refuse( - 'Your USDC is in your Sui address balance or across too many small coins, and this cash-out can only spend coins.' + `Your ${token.symbol} is in your Sui address balance or across too many small coins, and this ${action} can only spend coins.` ) } - return refuse('This wallet holds less USDC than this order’s amount.') + return refuse(`This wallet holds less ${token.symbol} than this order’s amount.`) } async function build(expectation: DepositExpectation): Promise { const units = orderUnits(expectation) const me = normalizeSuiAddress(expectation.account) - const coins = await coinsCovering(me, BigInt(units)) + const token = tokenOf(expectation) + const coins = await coinsCovering(me, BigInt(units), expectation) const tx = new Transaction() tx.setSender(me) @@ -226,14 +244,16 @@ async function build(expectation: DepositExpectation): Promise { const [coin, ...others] = coins.map((c) => tx.object(Inputs.ObjectRef({ objectId: c.objectId, version: c.version, digest: c.digest })) ) - if (coin === undefined) return refuse('This wallet holds less USDC than this order’s amount.') + if (coin === undefined) { + return refuse(`This wallet holds less ${token.symbol} than this order’s amount.`) + } if (others.length > 0) tx.mergeCoins(coin, others) const [split] = tx.splitCoins(coin, [BigInt(units)]) tx.transferObjects([split as never], normalizeSuiAddress(expectation.depositAddress)) let transaction: string try { - transaction = await buildUnsigned(tx, suiClient(), USDC.symbol) + transaction = await buildUnsigned(tx, suiClient(), token.symbol) } catch (e) { return refuse( e instanceof ChainActionRefused ? e.message : 'Sui’s network could not build this just now.' @@ -244,20 +264,25 @@ async function build(expectation: DepositExpectation): Promise { return payload } -/** What the bytes can't show: that each coin spent really is USDC, and the account's own. */ -async function assertSpendsOwnUsdc(ids: string[], account: string): Promise { +/** What the bytes can't show: that each coin spent really is the token, and the account's own. */ +async function assertSpendsOwnToken( + ids: string[], + account: string, + token: ChainToken +): Promise { const me = normalizeSuiAddress(account) + const wanted = coinTypeOf(token) const { objects } = await read(suiClient().core.getObjects({ objectIds: ids })) - const allOwnUsdc = + const allOwn = objects.length === ids.length && objects.every( (object) => !(object instanceof Error) && - normalizeStructTag(object.type) === USDC_COIN && + normalizeStructTag(object.type) === wanted && object.owner.$kind === 'AddressOwner' && normalizeSuiAddress(object.owner.AddressOwner) === me ) - if (!allOwnUsdc) refuse('A coin in this transaction is not USDC held by your wallet.') + if (!allOwn) refuse(`A coin in this transaction is not ${token.symbol} held by your wallet.`) } async function submit( @@ -267,16 +292,16 @@ async function submit( if (signed.family !== 'sui') { return { ok: false, error: 'That is not a Sui transaction, so nothing was sent.' } } + const token = tokenOf(expectation) const ids = spentCoins(signed.transaction, expectation) - await assertSpendsOwnUsdc(ids, expectation.account) + await assertSpendsOwnToken(ids, expectation.account, token) try { const result = await submitSui({ account: expectation.account, venueId: 'weave', signed }) - // submitSui words a failure for a trade, which a person cashing out never made. + // submitSui words a failure for a trade, and what failed here is only the deposit. return !result.ok && result.pending !== true && result.error.includes('trade') ? { ok: false, - error: - 'This deposit failed on the network, so your USDC did not move and only the network fee was spent. You can try again.', + error: `This deposit failed on the network, so your ${token.symbol} did not move and only the network fee was spent. You can try again.`, } : result } catch { @@ -285,7 +310,7 @@ async function submit( return { ok: false, pending: true, - error: `Your USDC deposit was sent (${digest}) but isn't confirmed yet. Don't send it again until you've checked https://suiscan.xyz/mainnet/tx/${digest}.`, + error: `Your ${token.symbol} deposit was sent (${digest}) but isn't confirmed yet. Don't send it again until you've checked https://suiscan.xyz/mainnet/tx/${digest}.`, } } } diff --git a/apps/dapp/lib/weave/types.ts b/apps/dapp/lib/weave/types.ts index b6b3e1c6..630fb36b 100644 --- a/apps/dapp/lib/weave/types.ts +++ b/apps/dapp/lib/weave/types.ts @@ -1,4 +1,4 @@ -import type { SignedPayload, SigningPayload, SubmitResult } from '../chains/types' +import type { ChainToken, SignedPayload, SigningPayload, SubmitResult } from '../chains/types' /** The chains a person can cash out from through Weave. Only USDC, on every one of them. */ export type CashoutChain = 'stellar' | 'solana' | 'avalanche' | 'sui' | 'near' @@ -41,15 +41,20 @@ export interface DepositExpectation { depositAddress: string /** Absent when the order has no memo. Refused by chains that cannot carry one. */ memo: string | undefined - /** USDC in display units. */ + /** The token in display units. */ amount: string + /** + * What to transfer. Absent means USDC, which is all a Weave order ever sends. Solana and Sui + * move the token named, Avalanche refuses any but USDC, and Stellar and NEAR are never given one. + */ + token?: ChainToken | undefined } /** * What each chain supplies for a deposit. `assertSafe` is the check that matters: - * it reads the payload back and admits only one USDC transfer from `account` to the - * order's deposit address for exactly the order's amount, and it throws - * `ChainActionRefused` on anything else. + * it reads the payload back and admits only one transfer of the expectation's token, USDC when + * none is named, from `account` to the order's deposit address for exactly the order's amount, + * and it throws `ChainActionRefused` on anything else. */ export interface DepositChain { chain: CashoutChain diff --git a/apps/dapp/public/images/venues/1click-avalanche.webp b/apps/dapp/public/images/venues/1click-avalanche.webp new file mode 100644 index 00000000..bfb03c75 Binary files /dev/null and b/apps/dapp/public/images/venues/1click-avalanche.webp differ diff --git a/apps/dapp/public/images/venues/1click-monad.webp b/apps/dapp/public/images/venues/1click-monad.webp new file mode 100644 index 00000000..bfb03c75 Binary files /dev/null and b/apps/dapp/public/images/venues/1click-monad.webp differ diff --git a/apps/dapp/public/images/venues/1click-solana.webp b/apps/dapp/public/images/venues/1click-solana.webp new file mode 100644 index 00000000..bfb03c75 Binary files /dev/null and b/apps/dapp/public/images/venues/1click-solana.webp differ diff --git a/apps/dapp/public/images/venues/1click-sui.webp b/apps/dapp/public/images/venues/1click-sui.webp new file mode 100644 index 00000000..bfb03c75 Binary files /dev/null and b/apps/dapp/public/images/venues/1click-sui.webp differ diff --git a/docs/superpowers/plans/2026-11-04-agents-on-every-chain.md b/docs/superpowers/plans/2026-11-04-agents-on-every-chain.md new file mode 100644 index 00000000..2bd234b7 --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-agents-on-every-chain.md @@ -0,0 +1,196 @@ +# Agent Competition on Every Chain Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A swap, supply or stake typed on NEAR, Solana, Sui, Avalanche or Monad runs the same agent competition as Stellar, and the winning plan is signed through that chain's existing safety checks. + +**Architecture:** `/api/agents/compete` gets a second branch for non-Stellar chains. It takes a `ChainActionRequest` rather than a `ParsedIntent`, asks a per-chain `quoteChainRoutes()` for priced routes, and streams the same frames, so the panel is unchanged. A route's `quote` is `{ kind: 'chain', request, venueId }`. Review calls the existing `chainAction.prepare`, so `assertSafe`, signing and submit all apply. Avalanche and Monad gain a `ChainModule` with one venue, 1Click. + +**Tech Stack:** Next 15, TypeScript, vitest, viem and wagmi, NEAR Intents 1Click REST. + +**Spec:** None written. The requirement is the user's "agentic competition on all chain" (2026-11-04). The research is in this plan's Facts section. + +## Facts this plan rests on + +Live dry quotes, $5, 2026-11-04, on `https://1click.chaindefuser.com/v0/quote`: + +| Chain | USDC to native | Native to USDC | 1Click tokens | +| --------- | -------------------------------- | -------------- | ---------------- | +| NEAR | HTTP 201 | HTTP 201 | 39 | +| Solana | HTTP 201 | HTTP 201 | 22 | +| Sui | HTTP 201 | HTTP 201 | SUI, USDC | +| Avalanche | HTTP 201 | HTTP 201 | AVAX, USDC, USDT | +| Monad | HTTP 201 | HTTP 201 | MON, USDC, USDT0 | +| Stellar | HTTP 400 `Incorrect depositMode` | same | not used here | + +Routes each chain will have: + +| Chain | Swap | Supply | Stake | +| --------- | --------------- | ----------------------------------- | ---------------- | +| NEAR | 1Click | 1Click Earn | Meta Pool, rNEAR | +| Solana | Jupiter, 1Click | Jupiter Lend, Kamino, Lulo with key | none | +| Sui | Cetus, 1Click | NAVI | SpringSui | +| Avalanche | 1Click | none | none | +| Monad | 1Click | none | none | + +## Global Constraints + +- Stellar's competition, swap, sequence and limit paths do not change. Every new branch tests `chain !== 'stellar'`. +- No edits to `components/intents/naira-payout-card.tsx`, `bank-picker.tsx` or `hooks/use-naira-payout.ts`. +- Agents choose a route id only. They never supply an amount, a recipient or a token (`tool-schema.ts` already has none). +- Race quotes must be `dry: true`. A deposit address is created only when the user presses Review. One press, one address. +- Every signed payload goes through `prepareChainAction` and then `venue.assertSafe`. Chain routes must never reach `useSwapExecution`. +- One route still races, as the user asked, and the panel says "One route available on {chain}. The agents are judging whether it's worth taking." +- On non-Stellar chains `executionMode` is forced to `fill`, `thenAction` to `none`. `rest` and `split` are not offered. +- No new dependencies. Commit subjects lowercase, header at most 100 characters, no co-author lines, no attribution footer. +- Disk is nearly full (about 7 GB free): do not start a second dev server or `next build`. Use vitest and `tsc --noEmit`. +- Work on a new branch `feat/agents-every-chain`, created from `feat/multichain-wallet-gate` with `git switch -c`, which changes no files and leaves the user's dev server running. + +## Review Focus + +- A token the chain doesn't list, such as "swap 5 FOO to USDC" on Solana: no race, no agent calls, the existing refusal is shown. Tested in Task 1. +- Quotes that all fail, such as 1Click down: agents are not called and the user sees a sentence. Tested in Task 1. +- 66-character Sui addresses reaching the rate limiter: the account is kept, not dropped. Tested in Task 1. +- Decimals of 6, 9, 18 and 24: the winner is the route with the better dollar output. Tested in Task 2. +- A second Review press on a NEAR, Avalanche or Monad route: no second deposit address while the first is on screen. Tested in Task 4. + +--- + +### Task 1: Compete route takes a chain request + +**Files:** + +- Create: `apps/dapp/lib/agents/chain-routes.ts` +- Modify: `apps/dapp/app/api/agents/compete/route.ts` +- Modify: `apps/dapp/lib/chains/types.ts` +- Test: `apps/dapp/lib/__tests__/agents-chain-compete.test.ts` + +**Interfaces:** + +- Produces: `interface ChainRouteQuote { kind: 'chain'; request: ChainActionRequest; venueId: string; venueName: string; pay: { amount: string; symbol: string }; receive?: { amount: string; symbol: string }; apyPercent?: number; valueInUsd?: number; valueOutUsd?: number }` +- Produces: `quoteChainRoutes(chainApi: ChainModule, request: ChainActionRequest, account: string): Promise`, with ids `${venueId}-1`, calling each usable venue's new optional `ChainVenue.quote`. +- Produces: `ChainVenue.quote?: (request: ChainActionRequest, account: string) => Promise` and `VenueQuote = { receive?: { amount: string; symbol: string }; apyPercent?: number; valueOutUsd?: number }`. +- Produces: compete body field `chainAction?: ChainActionRequest`. + +- [ ] **Step 1: Write failing tests**, with `chainModule` mocked: `chain: 'near'` with `chainAction` streams `competition:started`, not `chain_unsupported`; a body without `chainAction` on `near` gets `chain_unsupported`; a Stellar body takes the old path; an unknown token ends in `competition:error` with no `brain.propose` call; all quotes failing ends in `competition:error` with no `brain.propose` call; `account` of 66 characters reaches `enforceRateLimit`. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/agents-chain-compete.test.ts` from `apps/dapp`. Expected: FAIL. +- [ ] **Step 3: Implement.** Replace `EXECUTING_CHAINS` with a check that `chain === 'stellar' || chainModule(chain) !== undefined`. Raise the account limit to 128. Add a branch after the roster check that builds `market.routes` from `quoteChainRoutes`, skips `parseIntent`, `resolveAsset`, `fetchOrderBookTop` and `ensureLiveAssets`, and builds a minimal `ParsedIntent` via a new `intentFromChainAction()` so `brain.propose` and logging keep their types. +- [ ] **Step 4: Run** the same command. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): let the agent race take a chain action`. + +### Task 2: Prompts and scoring tell the truth per chain + +**Files:** + +- Modify: `apps/dapp/lib/agents/brief.ts`, `apps/dapp/lib/agents/brains/openai-compatible.ts:309,354,366`, `apps/dapp/lib/agents/market-context.ts:145,208`, `apps/dapp/lib/agents/measure.ts` +- Modify: `apps/dapp/app/api/agents/compete/route.ts` (`measured`, `settlePlan`) +- Test: `apps/dapp/lib/__tests__/agents-chain-prompt.test.ts` + +**Interfaces:** + +- Consumes: `ChainRouteQuote` (Task 1). +- Produces: `systemPromptFor(chain: string): string`; `measureChainRoute(q: ChainRouteQuote): { outUsd: number; vsInPct: number } | undefined`; `settleChainPlan(p: AgentProposalResult): AgentProposalResult`. + +- [ ] **Step 1: Write failing tests:** the prompt for `near` contains neither "testnet", "anchor", "rest" nor "split"; for `stellar` it equals today's `SYSTEM_PROMPT`; `measureChainRoute` ranks $4.97 out above $4.74 out from quotes in 6, 9, 18 and 24 decimals; a proposal with `executionMode: 'rest'` and `thenAction: 'lend'` becomes `fill` and `none`; `buildMarketContext('solana')` offers only venues with `integration: 'executes'`, and no Stellar tokens. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/agents-chain-prompt.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Supply and stake routes rank on `apyPercent`, using each venue's existing rate reader (`jupiter-lend.ts:140`, `kamino.ts:109`, `near/stake.ts:53`). Prices come from `chainApi.priceUsd`. +- [ ] **Step 4: Run** the same command. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): give agents each chain's own facts`. + +### Task 3: NEAR races (hackathon, ships first) + +**Files:** + +- Modify: `apps/dapp/lib/chains/near/swap.ts`, `apps/dapp/lib/chains/near/stake.ts`, `apps/dapp/lib/chains/near/earn.ts` +- Test: `apps/dapp/lib/__tests__/near-race-quote.test.ts` + +**Interfaces:** + +- Consumes: `ChainVenue.quote` (Task 1). +- Produces: `quote` on `oneClickVenue`, `metapoolVenue`, `rnearVenue`, `earnVenue`. + +- [ ] **Step 1: Write failing tests:** `oneClickVenue.quote` posts `dry: true` and returns `receive` and `valueOutUsd`; it runs `assertQuoteMatches`, reusing that check; it never reads `depositAddress`; each stake venue returns its `apyPercent`. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/near-race-quote.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement** by reusing `requestQuote` and the preview half of `prepareOneClickDeposit`. +- [ ] **Step 4: Run** the same command. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): quote near venues for the agent race`. + +### Task 4: The chat runs the race and Review signs the winner + +**Files:** + +- Modify: `apps/dapp/hooks/use-competition.ts`, `apps/dapp/components/intents/intent-chat.tsx` (the `parseChainAction` branch, `handleExecute`, `locked`), `apps/dapp/components/intents/competition-panel.tsx`, `apps/dapp/lib/orchestrate/capabilities.ts:11` +- Create: `apps/dapp/lib/agents/chain-race.ts` +- Test: `apps/dapp/lib/__tests__/agents-chain-race.test.ts` + +**Interfaces:** + +- Consumes: compete `chainAction` (Task 1), `ChainRouteQuote` (Task 1). +- Produces: `chainRequestFor(route: unknown, asked: ChainActionRequest): ChainActionRequest | undefined`, which returns the request with `venue` set and refuses anything whose token or amount differs from `asked`. +- Produces: `useCompetition` option `chainAction`, sent in place of `parsed`. + +- [ ] **Step 1: Write failing tests:** `chainRequestFor` sets `venue`; it returns undefined when a route's amount, `tokenIn` or `tokenOut` differs from `asked`; it returns undefined for a Stellar `SwapQuote`; `singleRouteNote(1, 'NEAR')` returns the Global Constraints sentence; `singleRouteNote(2, 'NEAR')` returns undefined; a second call while one prepare is pending does not call `prepare` again. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/agents-chain-race.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** In the chat, `parseChainAction` success starts the race rather than `chainAction.prepare`. `handleExecute` on non-Stellar calls `chainAction.prepare(chainRequestFor(route, asked), text)`. If the race ends in an error frame, call `chainAction.prepare(asked, text)` so the chain keeps working when agents are offline. Include `chainAction.phase` signing and submitting in `locked`. +- [ ] **Step 4: Run** the same command and `pnpm exec tsc --noEmit`. Expected: PASS, exit 0. +- [ ] **Step 5: Commit** `feat(dapp): run the agent race on near from chat`. + +### Task 5: Solana and Sui race, with 1Click as a second route + +**Files:** + +- Create: `apps/dapp/lib/chains/oneclick-origin.ts` +- Modify: `apps/dapp/lib/chains/near/oneclick.ts` (`assetIdOf` by chain), `apps/dapp/lib/chains/solana/jupiter-swap.ts`, `solana/jupiter-lend.ts`, `solana/kamino.ts`, `solana/lulo.ts`, `solana/module.ts`, `sui/cetus.ts`, `sui/navi.ts`, `sui/springsui.ts`, `sui/module.ts` +- Test: `apps/dapp/lib/__tests__/oneclick-origin.test.ts`, `apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts` + +**Interfaces:** + +- Consumes: `solanaDeposit` and `suiDeposit` builders and asserters in `lib/weave/deposit/`. +- Produces: `oneClickOriginVenue(chain: 'solana' | 'sui' | 'avalanche' | 'monad'): ChainVenue`, whose `prepare` takes a `dry: false` quote and builds one transfer to its deposit address, whose `assertSafe` admits only that transfer, and whose `submit` confirms it and follows `depositStatus`. +- Produces: `oneClickAssetId(chain, token): Promise` from `listTokens()`, matched on chain and contract, refusing a symbol match alone. + +- [ ] **Step 1: Write failing tests:** the Jupiter `quote` uses `/swap/v2/order` and returns `receive` without exposing `transaction`; the Cetus `quote` returns its best route; `oneClickAssetId` refuses a look-alike symbol on another contract; `assertSafe` refuses a wrong recipient, wrong amount and an extra transfer; a deposit memo is refused; a native SOL or SUI input is refused with a plain sentence, so only USDC and USDT reach 1Click in this task. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/oneclick-origin.test.ts lib/__tests__/solana-sui-race-quote.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Generalise the deposit builders by passing the token rather than assuming USDC, keeping their tests green. Add the venue to `venues` in `solana/module.ts` and `sui/module.ts`. Add its row and logo per `feedback-always-add-venue-logos`, which `venue-logos.test.ts` enforces. +- [ ] **Step 4: Run** the same command plus `pnpm exec vitest run lib/__tests__/weave-deposit`. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): race jupiter and cetus against 1click`. + +### Task 6: Avalanche and Monad can swap, and race + +**Files:** + +- Create: `apps/dapp/lib/chains/evm/module.ts`, `apps/dapp/lib/chains/evm/tokens.ts`, `apps/dapp/lib/chains/evm/submit.ts` +- Modify: `apps/dapp/lib/chains/types.ts` (`ChainWithActions`, evm `value`), `apps/dapp/lib/chains/registry.ts`, `apps/dapp/lib/chains/capabilities.ts`, `apps/dapp/lib/adapters/evm-adapter.ts` (Monad `signPrepared`, non-zero `value`), `apps/dapp/app/api/chain/[chain]/submit/route.ts` (`evm` in the union, family check), `apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts:512` +- Test: `apps/dapp/lib/__tests__/evm-module.test.ts` + +**Interfaces:** + +- Consumes: `oneClickOriginVenue` (Task 5). +- Produces: `avalancheModule`, `monadModule: ChainModule`, with tokens AVAX, USDC, USDT on Avalanche and MON, USDC, USDT0 on Monad, contract addresses read from 1Click's list and checked against `0xb97ef9ef8734...` for Avalanche USDC and `0x754704bc059f...` for Monad USDC. + +- [ ] **Step 1: Write failing tests:** `chainModule('monad')` and `chainModule('avalanche')` are defined; a native AVAX transfer payload passes `assertSafe` only with `value` equal to the quote's amount and empty `data`; an ERC-20 payload passes only with `value: '0'`; a Monad `chainId` of 143 is required, and 43114 is refused on Monad; the submit route accepts `{ family: 'evm', hash }` for both; a receipt from another account is refused. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/evm-module.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Confirm Monad's chain id with `viem/chains` before writing it. Reuse the Avalanche deposit's receipt check for ERC-20 and write the equivalent for native. +- [ ] **Step 4: Run** `pnpm exec vitest run lib/__tests__/evm-module.test.ts lib/__tests__/weave-deposit-avalanche.test.ts`. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): swap and race on avalanche and monad through 1click`. + +### Task 7: Record which chain each race ran on + +**Files:** + +- Modify: `apps/dapp/lib/server/analytics.ts:65`, `apps/dapp/app/api/agents/history/route.ts`, `apps/dapp/app/api/chain/[chain]/submit/route.ts:73-80` +- Create: `packages/db/migrations/014_agent_races_chain.sql` +- Test: extend `apps/dapp/lib/__tests__/agents-chain-compete.test.ts` + +- [ ] **Step 1: Write failing tests:** `logRace` writes `chain`; the history route accepts a Solana, Sui, NEAR and EVM account rather than only `StrKey`; a chain submit carries the race id it came from. +- [ ] **Step 2: Run** the test file. Expected: FAIL. +- [ ] **Step 3: Implement,** with `ALTER TABLE agent_races ADD COLUMN IF NOT EXISTS chain TEXT NOT NULL DEFAULT 'stellar'` in both the migration and the code's DDL. +- [ ] **Step 4: Verify the whole change.** Run `pnpm exec tsc --noEmit`, `pnpm exec next lint --quiet` and `pnpm exec vitest run --maxWorkers=4 --minWorkers=1`. Expected: exit 0 for the first two. The only failures allowed are live-network tests in code this plan doesn't touch, and each must be named in the report. +- [ ] **Step 5: Commit** `feat(dapp): record the chain of every agent race`. + +## Not in this plan + +- Agents choosing limit orders, swap-then-deposit, or cash-out on these chains. They need those features to exist first. +- A second swap venue on NEAR (Rhea needs `ft_transfer_call`, which `near/safety.ts:10` refuses), Avalanche or Monad (neither is covered by the integration report), so these three start with one swap route. +- Native SOL and SUI through 1Click. Jupiter and Cetus still take them. +- A real signed transaction: the user does that with a funded wallet. No agent in this plan may sign or send. diff --git a/packages/db/migrations/014_agent_races_chain.sql b/packages/db/migrations/014_agent_races_chain.sql new file mode 100644 index 00000000..a77164c5 --- /dev/null +++ b/packages/db/migrations/014_agent_races_chain.sql @@ -0,0 +1,5 @@ +-- The chain each agent race ran on. Races from before other chains ran were all Stellar's. +-- The application runs the same statement on first use; keep this in sync with +-- ANALYTICS_DDL in apps/dapp/lib/server/analytics.ts. + +ALTER TABLE agent_races ADD COLUMN IF NOT EXISTS chain TEXT NOT NULL DEFAULT 'stellar';