diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 26f715b3..1912e04a 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -92,6 +92,7 @@ ACCESS_GATE= # the client's copy. Behind any other proxy that only appends to # x-forwarded-for, a client can choose its own bucket. # RATE_LIMIT_BUILD=30/60 +# RATE_LIMIT_READ=120/60 # RATE_LIMIT_SUBMIT=10/60 # RATE_LIMIT_COMPETE=10/60 # RATE_LIMIT_RESOLVE=30/60 diff --git a/apps/dapp/app/[chain]/intents/new/page.tsx b/apps/dapp/app/[chain]/intents/new/page.tsx index f891cb85..ff230309 100644 --- a/apps/dapp/app/[chain]/intents/new/page.tsx +++ b/apps/dapp/app/[chain]/intents/new/page.tsx @@ -2,26 +2,14 @@ import { parseChainSegment } from '@intent/config' import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { IntentChat } from '../../../../components/intents/intent-chat' -import { intentIntro } from '../../../../lib/intent-intro-copy' - export const metadata: Metadata = { title: 'New intent' } +/** The chat is drawn by `ChatHost` in the chain layout, under this page's own heading. */ export default async function NewIntentPage({ params, }: { params: Promise<{ chain: string }> -}): Promise { - const route = parseChainSegment((await params).chain) - if (route === undefined) notFound() - - return ( -
-
-

Submit an intent

-

{intentIntro(route.slug)}

-
- -
- ) +}): Promise { + if (parseChainSegment((await params).chain) === undefined) notFound() + return null } diff --git a/apps/dapp/app/[chain]/intents/page.tsx b/apps/dapp/app/[chain]/intents/page.tsx index 5d05275e..afb7d706 100644 --- a/apps/dapp/app/[chain]/intents/page.tsx +++ b/apps/dapp/app/[chain]/intents/page.tsx @@ -1,25 +1,11 @@ -'use client' - -import { IntentChat } from '../../../components/intents/intent-chat' - /** - * The composer, and only the composer. + * The composer, and only the composer: `ChatHost` in the chain layout draws it, + * so the chat stays mounted when the person goes to another page. * * History used to sit behind a second tab here, which hid a whole page of * settled trades and open positions behind a label most people never * clicked. It has its own page now, one step away in the sidebar. */ -export default function IntentsPage(): JSX.Element { - return ( -
-
-

Intents

-

- Say what you want. Agents compete to deliver it. -

-
- - -
- ) +export default function IntentsPage(): null { + return null } diff --git a/apps/dapp/app/[chain]/layout.tsx b/apps/dapp/app/[chain]/layout.tsx index 3dd77a70..7d5571ff 100644 --- a/apps/dapp/app/[chain]/layout.tsx +++ b/apps/dapp/app/[chain]/layout.tsx @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { notFound } from 'next/navigation' import { WalletGate } from '../../components/connect/wallet-gate' +import { ChatHost } from '../../components/intents/chat-host' import { AppShell } from '../../components/layout/app-shell' import { tradeCapUsd } from '../../lib/server/trade-cap' import { ChainProvider } from '../../providers/chain-provider' @@ -46,7 +47,9 @@ export default async function ChainLayout({ return ( - {children} + + {children} + ) diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 534d6017..804e6586 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -16,7 +16,11 @@ 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 { + assertAmountWithinCap, + assertKnownTokens, + assertNativeReserve, +} from '../../../../lib/chains/prepare' import { chainModule } from '../../../../lib/chains/registry' import { ChainActionRefused, @@ -144,6 +148,13 @@ async function chainRaceFacts( if (account === undefined) { return 'No wallet account came with this request, so nothing could be priced. Nothing was signed or sent.' } + try { + await assertNativeReserve(chainApi, action, account) + } catch (e) { + // Worded in full already, ending as every refusal here does. + if (e instanceof ChainActionRefused) return { refusal: e.message } + throw e + } const routes = await quoteChainRoutes(chainApi, action, account) if (routes.length === 0) { diff --git a/apps/dapp/app/api/chain/[chain]/balances/route.ts b/apps/dapp/app/api/chain/[chain]/balances/route.ts new file mode 100644 index 00000000..2ebc8c11 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/balances/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server' + +import { chainModule } from '../../../../../lib/chains/registry' +import { BALANCE_DEADLINE_MS, ChainActionRefused } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 20 + +/** Every token the account holds on this chain, read when asked and never kept. */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const account = new URL(request.url).searchParams.get('account') ?? '' + try { + const balances = await chainApi.balances(account, AbortSignal.timeout(BALANCE_DEADLINE_MS)) + return NextResponse.json(balances, { headers: { 'cache-control': 'no-store' } }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + reportError('chain/balances', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'balances_unavailable' }, { status: 503 }) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/follow-on/route.ts b/apps/dapp/app/api/chain/[chain]/follow-on/route.ts new file mode 100644 index 00000000..006f0ce6 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/follow-on/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { assertFollowOnCanRun } from '../../../../../lib/chains/prepare' +import { chainModule } from '../../../../../lib/chains/registry' +import { ChainActionRefused } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' +import { isWalletAddress } from '../../../../../lib/wallet/address' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const NOTHING_MOVED = 'Nothing was signed or sent.' + +const Body = z.object({ + account: z.string().min(1).max(128), + step: z.object({ + kind: z.enum(['supply', 'stake', 'send']), + tokenIn: z.string().min(1).max(16), + venue: z.string().min(1).max(40).optional(), + recipient: z.string().min(1).max(128).optional(), + }), +}) + +/** Whether a step could run after a swap, asked before the swap is signed. Builds nothing. */ +export async function POST( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const parsed = Body.safeParse(await request.json().catch(() => undefined)) + if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + if (!isWalletAddress(parsed.data.account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + try { + const { kind, tokenIn, venue, recipient } = parsed.data.step + await assertFollowOnCanRun( + chainApi, + { + kind, + tokenIn, + ...(venue !== undefined ? { venue } : {}), + ...(recipient !== undefined ? { recipient } : {}), + }, + parsed.data.account + ) + return NextResponse.json({ ok: true }) + } catch (e) { + if (e instanceof ChainActionRefused) { + const said = e.message.endsWith(NOTHING_MOVED) ? e.message : `${e.message} ${NOTHING_MOVED}` + return NextResponse.json({ error: said }, { status: 422 }) + } + reportError('chain/follow-on', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'unreadable' }, { status: 502 }) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts index bcbc7722..2162df90 100644 --- a/apps/dapp/app/api/chain/[chain]/prepare/route.ts +++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts @@ -14,7 +14,7 @@ export const dynamic = 'force-dynamic' const Body = z.object({ account: z.string().min(1).max(128), request: z.object({ - kind: z.enum(['swap', 'supply', 'stake']), + kind: z.enum(['swap', 'supply', 'stake', 'send']), amount: z .string() .regex(/^\d+(\.\d+)?$/) @@ -22,6 +22,7 @@ const Body = z.object({ tokenIn: z.string().min(1).max(16), tokenOut: z.string().min(1).max(16).optional(), venue: z.string().min(1).max(40).optional(), + recipient: z.string().min(1).max(128).optional(), }), }) @@ -43,7 +44,7 @@ export async function POST( } try { - const { kind, amount, tokenIn, tokenOut, venue } = parsed.data.request + const { kind, amount, tokenIn, tokenOut, venue, recipient } = parsed.data.request const prepared = await prepareChainAction( chainApi, { @@ -52,6 +53,7 @@ export async function POST( tokenIn, ...(tokenOut !== undefined ? { tokenOut } : {}), ...(venue !== undefined ? { venue } : {}), + ...(recipient !== undefined ? { recipient } : {}), }, parsed.data.account ) diff --git a/apps/dapp/app/api/chain/[chain]/price/route.ts b/apps/dapp/app/api/chain/[chain]/price/route.ts new file mode 100644 index 00000000..7a7fa4cf --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/price/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' + +import { chainModule } from '../../../../../lib/chains/registry' +import { tokenBySymbol } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** A listed token's dollar price, from the same source the race uses, to size "$5 of SOL". */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const token = tokenBySymbol(chainApi.tokens, new URL(request.url).searchParams.get('token') ?? '') + if (token === undefined) return NextResponse.json({ error: 'unknown_token' }, { status: 404 }) + + try { + const priceUsd = await chainApi.priceUsd(token) + if (priceUsd === undefined) { + return NextResponse.json({ error: 'price_unavailable' }, { status: 503 }) + } + return NextResponse.json( + { priceUsd, decimals: token.decimals }, + { headers: { 'cache-control': 'no-store' } } + ) + } catch (e) { + reportError('chain/price', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'price_unavailable' }, { status: 503 }) + } +} diff --git a/apps/dapp/app/api/intent/orchestrate/route.ts b/apps/dapp/app/api/intent/orchestrate/route.ts index 8c4f7978..b206bab4 100644 --- a/apps/dapp/app/api/intent/orchestrate/route.ts +++ b/apps/dapp/app/api/intent/orchestrate/route.ts @@ -3,13 +3,17 @@ import { z } from 'zod' import { LENDABLE_ONLY, + chainReadPolicy, + tokenHintIn, OFFRAMP_ANCHORS, lendingVenuesOn, unknownTickers, } from '../../../../lib/intent-read-policy' +import { SEQUENCE_CASHOUT_CHAINS } from '../../../../lib/chains/sequence' import { orchestrateWithLlm } from '../../../../lib/orchestrate/orchestrate' import { isLlmParseConfigured } from '../../../../lib/parse-intent-llm' import { logIntentRead } from '../../../../lib/server/analytics' +import { cashOutPrompt } from '../../../../lib/weave/corridors' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { resolveVerifiedAsset, tradeableSymbols } from '../../../../lib/swap/asset-registry' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' @@ -74,9 +78,12 @@ export async function POST(request: Request): Promise { // The latest thing the user typed is what names a token for the first time. const latest = [...turns].reverse().find((t) => t.role === 'user')?.content ?? '' + // Off Stellar a reading is held to the chain's own tokens and venues, not Stellar's registry. + const onChain = chainReadPolicy(chain) + // A token named in the sentence that the app does not hold by hand is looked // up and, only if its issuer proves itself, admitted for this request. - await ensureLiveAssets(tickersIn(latest)) + if (onChain === undefined) await ensureLiveAssets(tickersIn(latest)) const result = await orchestrateWithLlm(turns, { chain, @@ -85,15 +92,29 @@ export async function POST(request: Request): Promise { // The model may only name assets this app can actually trade. Checked here // rather than trusted, because a hallucinated ticker reaching the quoter as // a real instruction is a trap this codebase has hit once already. - allowedSymbols: [...tradeableSymbols(), ...(LENDABLE_ONLY[chain] ?? [])], - allowedVenues: lendingVenuesOn(chain), - allowedAnchors: OFFRAMP_ANCHORS[chain] ?? [], + allowedSymbols: onChain?.symbols ?? [...tradeableSymbols(), ...(LENDABLE_ONLY[chain] ?? [])], + allowedVenues: onChain?.venues ?? lendingVenuesOn(chain), + // The only payout off Stellar is Weave's, and only on a chain whose swaps can end in one. + allowedAnchors: + onChain === undefined + ? (OFFRAMP_ANCHORS[chain] ?? []) + : (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) && + cashOutPrompt(chain) !== '' + ? ['weave'] + : [], + // Off Stellar a follow-on that can't be honoured is refused before the swap, not dropped. + ...(onChain !== undefined ? { reportRefusedFollowOn: true } : {}), }) if (result === null) { // A ticker typed in capitals that nothing here recognises is most likely // the reason. Saying so lets the chat ask which token was meant. - const unknown = unknownTickers(latest) + const hint = onChain === undefined ? undefined : tokenHintIn(chain, latest) + if (hint !== undefined) { + await logIntentRead({ understood: false, reason: 'unclear' }) + return NextResponse.json({ outcome: 'unclear', reply: hint }) + } + const unknown = onChain === undefined ? unknownTickers(latest) : [] if (unknown.length > 0) { await logIntentRead({ understood: false, reason: 'unknown_token' }) return NextResponse.json({ outcome: 'unavailable', reason: 'unknown_token', tokens: unknown }) @@ -164,16 +185,22 @@ export async function POST(request: Request): Promise { // Null rather than omitted, so the client distinguishes "no follow-on" // from a field that failed to serialise. followOn: reading.followOn, + ...(reading.followOnRefused !== undefined + ? { followOnRefused: reading.followOnRefused } + : {}), style: result.style, ...(result.amountOf !== undefined ? { amountOf: result.amountOf } : {}), ...(result.limitPriceUsd !== undefined ? { limitPriceUsd: result.limitPriceUsd } : {}), instruction: result.instruction, context: result.context, // Tokens admitted live, so the page can trade them without a second lookup. - liveAssets: [reading.tokenIn, reading.tokenOut].flatMap((symbol) => { - const asset = resolveVerifiedAsset(symbol) - return asset !== undefined && asset.category === 'token' ? [asset] : [] - }), + liveAssets: + onChain !== undefined + ? [] + : [reading.tokenIn, reading.tokenOut].flatMap((symbol) => { + const asset = resolveVerifiedAsset(symbol) + return asset !== undefined && asset.category === 'token' ? [asset] : [] + }), }) } } diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx index 71992603..5cded506 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -41,6 +41,7 @@ import { type CashoutOrderView, type CashoutQuote, } from '../../lib/weave/cashout-view' +import { currencyFlag, payoutWord } from '../../lib/weave/currency-flag' import { isCashoutChain } from '../../lib/weave/types' import { useChain } from '../../providers/chain-provider' import { TokenIcon } from '../ui/token-icon' @@ -660,6 +661,9 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu const canReview = isValidAmount(amount) && estimate !== undefined && !overBalance && holder.data !== undefined const bankName = banks?.find((b) => b.code === bankCode)?.name + const word = payoutWord(institutions.data?.map((i) => i.type) ?? []) + const holderWord = picked?.type === 'mobile_money' ? 'mobile money provider' : 'bank' + const flag = currencyFlag(currency) const creating = phase === 'creating' return ( @@ -669,7 +673,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu subtitle={ isNaira ? 'Paid straight to a Nigerian bank account' - : `Paid straight to a bank or mobile money account in ${corridor?.country ?? currency}` + : `Paid straight to a ${word} account in ${corridor?.country ?? currency}` } /> @@ -715,8 +719,18 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
{estimate !== undefined ? ( - + ≈{' '} + {flag !== undefined ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${flag.country} + ) : null} {formatPayout(estimate.destAmount, currency)} @@ -729,7 +743,9 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu {quote.isError && quoteReady ? (quote.error as Error).message - : 'Enter an amount to see what the bank receives.'} + : isNaira + ? 'Enter an amount to see what the bank receives.' + : 'Enter an amount to see what you receive.'} )}
@@ -742,7 +758,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
- Checking with the bank… + Checking with the {holderWord}… ) : holder.data !== undefined && accountReady ? ( {titleCase(holder.data)} ) : ( - The bank can't confirm a name here, so check this number carefully. + The {holderWord} can't confirm a name here, so check this number carefully. )} diff --git a/apps/dapp/components/intents/cashout-currency-question.tsx b/apps/dapp/components/intents/cashout-currency-question.tsx index e42460b9..2903a709 100644 --- a/apps/dapp/components/intents/cashout-currency-question.tsx +++ b/apps/dapp/components/intents/cashout-currency-question.tsx @@ -2,12 +2,16 @@ import { cn } from '@intent/ui' +import type { ChainSequenceRequest } from '../../lib/chains/sequence' import { CURRENCY_QUESTION, type CashoutCurrency } from '../../lib/weave/cashout-view' +import { currencyFlag } from '../../lib/weave/currency-flag' import { agentBubble, userBubble } from './bubble' export interface CashoutQuestionState { amount?: string choices: CashoutCurrency[] + /** A swap waiting on this answer, on the chain it was asked on: answering starts its race, not the form. */ + sequence?: { request: ChainSequenceRequest; chain: string } /** What the person answered. Set once, and the question stays in the thread as asked. */ answer?: string /** An answer that couldn't be used, and what was said back. */ @@ -40,18 +44,31 @@ export function CashoutCurrencyQuestion({ ) : null} {open ? (
- {question.choices.map((choice) => ( - - ))} + {question.choices.map((choice) => { + const flag = currencyFlag(choice.code) + return ( + + ) + })}
) : (
diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 7905ea52..ceb093f1 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -4,10 +4,22 @@ import { Button, Card } from '@intent/ui' import { ExternalLink, Loader2 } from 'lucide-react' import type { ChainAction } from '../../hooks/use-chain-action' +import { stepErrorMessage } from '../../lib/agents/chain-race' /** 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, earlier } = action +export function ChainActionConfirm({ + action, + sequenceSettled = false, + typedStep, +}: { + action: ChainAction + /** This card is step 2, after a swap that was signed and settled. */ + sequenceSettled?: boolean + /** That step as a request of its own, for when it can't go on from here. */ + typedStep?: string | undefined +}): JSX.Element { + const { phase, prepared, hash, explorerUrl, earlier } = action + const error = stepErrorMessage(action.error, sequenceSettled, typedStep) const busy = phase === 'signing' || phase === 'submitting' return ( @@ -15,7 +27,7 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele {phase === 'building' ? (

) : null} @@ -48,9 +60,11 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele {prepared !== undefined && phase !== 'failed' && phase !== 'pending' ? ( <>
- {prepared.summary} + {prepared.summary} - You pay {prepared.pay.amount} {prepared.pay.symbol} on {prepared.venueName} + {prepared.kind === 'send' + ? `This moves ${prepared.pay.amount} ${prepared.pay.symbol} from your wallet` + : `You pay ${prepared.pay.amount} ${prepared.pay.symbol} on ${prepared.venueName}`} {prepared.receive !== undefined ? `, and receive ${prepared.receive.approximate ? 'about ' : ''}${prepared.receive.amount} ${prepared.receive.symbol}` : ''} diff --git a/apps/dapp/components/intents/chain-sequence-steps.tsx b/apps/dapp/components/intents/chain-sequence-steps.tsx new file mode 100644 index 00000000..ed4454c3 --- /dev/null +++ b/apps/dapp/components/intents/chain-sequence-steps.tsx @@ -0,0 +1,140 @@ +'use client' + +import { Button, Card, cn } from '@intent/ui' +import { Check, Loader2 } from 'lucide-react' +import { useEffect, useRef } from 'react' + +import type { ChainSequence } from '../../hooks/use-chain-sequence' +import { cashoutAmountFrom } from '../../lib/chains/sequence' +import { + deliveredLine, + secondStepLabel, + stepAmount, + stepLabels, + stepMarks, + type StepMark, +} from '../../lib/chains/sequence-flow' + +function Mark({ mark, number }: { mark: StepMark; number: number }): JSX.Element { + return ( + + ) +} + +/** Both steps of a swap and what follows it, from before anything is signed. */ +export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Element | null { + const { sequence, stage, delivered, error, settled } = steps + const ref = useRef(null) + + // The button and the form come up below a long thread, so they are brought into view. + const shownNow = stage === 'ready' || stage === 'next' ? stage : undefined + useEffect(() => { + if (shownNow === undefined) return + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches + ref.current?.scrollIntoView({ + behavior: reduce ? 'auto' : 'smooth', + block: shownNow === 'next' ? 'start' : 'nearest', + }) + }, [shownNow]) + + if (sequence === null || stage === undefined) return null + + const [first, second] = stepLabels(sequence) + const [firstMark, secondMark] = stepMarks(stage, settled) + // Too little to cash out offers no button. What can change is asked again at the press. + const offer = + stage === 'ready' && delivered !== undefined && sequence.followOn.kind === 'cashout' + ? cashoutAmountFrom(delivered) + : undefined + const step = + stage === 'ready' && delivered !== undefined + ? stepAmount(sequence, delivered, steps.quotedOut) + : undefined + const ownCard = step !== undefined ? secondStepLabel(sequence, step.amount) : undefined + + return ( +
+ +
    +
  1. + + {first} +
  2. +
  3. + + + {second} + +
  4. +
+ + {stage === 'swap' ? ( +

{steps.swapLine}

+ ) : null} + + {stage === 'measuring' ? ( +

+

+ ) : null} + + {stage === 'ready' && delivered !== undefined ? ( +

+ {deliveredLine(delivered, sequence)} +

+ ) : null} + + {step?.note !== undefined ? ( +

+ {step.note} +

+ ) : null} + + {offer !== undefined ? ( +
+ +
+ ) : null} + + {ownCard !== undefined ? ( +
+ +
+ ) : null} + + {stage === 'next' ? ( +

+ The form below is yours to finish or leave. Leaving it pays nothing out. +

+ ) : null} + + {error !== undefined ? ( +

+ {error} +

+ ) : null} +
+
+ ) +} diff --git a/apps/dapp/components/intents/chat-host.tsx b/apps/dapp/components/intents/chat-host.tsx new file mode 100644 index 00000000..e74ad6be --- /dev/null +++ b/apps/dapp/components/intents/chat-host.tsx @@ -0,0 +1,74 @@ +'use client' + +import { cn } from '@intent/ui' +import { usePathname, useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useRef, type ReactNode } from 'react' + +import { BANK_SEARCH_LABEL, showChatOnPath } from '../../lib/chat/live-session' +import { intentIntro } from '../../lib/intent-intro-copy' +import { useChain } from '../../providers/chain-provider' +import { IntentChat } from './intent-chat' + +/** A link that opens a rule still works when the chat is already mounted. */ +function ChatFromLink({ showing }: { showing: boolean }): JSX.Element { + const rule = useSearchParams().get('rule') + return ( + + ) +} + +/** + * Keeps the chat mounted while the person is on another page, so a race, a card + * or a payment being followed carries on and is there when they come back. + * + * Mounted on the first visit to the chat, so someone who never opens it never + * polls for it. The page itself renders nothing; what stands in for it while it + * loads is hidden too, or the spinner would sit under the chat. + */ +export function ChatHost({ children }: { children: ReactNode }): JSX.Element { + const pathname = usePathname() + const { segment, slug } = useChain() + const showing = showChatOnPath(pathname, segment) + const opened = useRef(false) + if (showing) opened.current = true + const hostRef = useRef(null) + + useEffect(() => { + if (showing) { + // Following a link opens at the top. The page's own scroll-to-top has nothing to find here. + hostRef.current?.scrollIntoView({ block: 'start' }) + return + } + // The bank list is drawn on the body, which `hidden` cannot reach. Escape is its own way out. + document + .querySelector(`input[aria-label="${BANK_SEARCH_LABEL}"]`) + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }, [showing]) + + const isNew = pathname === `/${segment}/intents/new` + + return ( + <> + {opened.current ? ( + + ) : null} +
{children}
+ + ) +} diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index 699b2dd3..077036c7 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -5,7 +5,12 @@ 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 { + panelErrorMessage, + recommendationLine, + singleRouteNote, + unansweredLine, +} from '../../lib/agents/chain-race' import { describePlan } from '../../lib/agents/competition' import { AgentAvatar } from '../agents/agent-avatar' import { agentBubble, systemPill } from './bubble' @@ -148,6 +153,10 @@ export function CompetitionPanel({ const revealedAgents = agents.filter((a) => revealed[a.key] && answered(a.key)) const pendingAgents = decided ? [] : agents.filter((a) => !revealed[a.key] && answered(a.key)) const anyAnswered = Object.values(proposals).some((p) => p.failed === undefined) + // No card for one, but a line says who left, so a short race doesn't look like the whole field. + const unanswered = unansweredLine( + agents.map((a) => ({ name: a.name, failed: proposals[a.key]?.failed })) + ) const routeNote = chainName !== undefined ? singleRouteNote(state.routeCount, chainName) : undefined @@ -339,6 +348,8 @@ export function CompetitionPanel({ : recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)} ) : null} + + {decided && unanswered !== undefined ?
{unanswered}
: null}
) } diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index c6c2b4af..5fd2301e 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -19,6 +19,20 @@ import { unclearMessage, } from '../../lib/chat/orchestrate-copy' import { rememberExchange } from '../../lib/chat/dialogue' +import { + appendLiveTurn, + cardIsStale, + chainOutcomeSentence, + clearLiveSession, + FINISH_SIGNATURE_NOTICE, + liveSessionStorage, + loadLiveSession, + ownedTurns, + ownerChange, + recordableReply, + WALLET_SWITCHED_NOTICE, + type LiveTurn, +} from '../../lib/chat/live-session' import { fixVerbTypo } from '../../lib/chat/typos' import { StrategyEditor, type StrategyEdits } from './strategy-editor' import { DEFAULT_SLIPPAGE_BPS } from '../../lib/swap/build-tx' @@ -26,6 +40,8 @@ import { ACKNOWLEDGED } from '../../lib/chat/clarify-flow' import type { ClarifyTurn } from '../../lib/clarify/clarify' import { chainReviewClosed, + raceClosed, + stepErrorMessage, chainSigning, isChainRoute, needsDirectRoute, @@ -62,17 +78,43 @@ import { StandingInboxPanel } from './standing-inbox-panel' import { useStandingRules } from '../../hooks/use-standing-rules' import { useStandingInbox } from '../../hooks/use-standing-inbox' import { ensureSession } from '../../lib/api/auth' +import { balanceReplyDecision, chainBalanceReply } from '../../lib/chains/balance-reply' 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 { sizeInUnits } from '../../lib/chains/dollar-amount' +import { chainHelp, helpForUnread } from '../../lib/chains/explain' +import { racesFirst } from '../../lib/chains/parse-action' +import { + cashoutStarts, + chainRouteFromReading, + messageStartsAction, + readChainMessage, +} from '../../lib/chains/parse-sequence' +import { followOnRefusal, type ChainSequenceRequest } from '../../lib/chains/sequence' +import { + CASHOUT_UNDER_WAY, + cashoutSequence, + currencyStep, + endedNote, + keepsRun, + questionAnswerFrom, + questionChainRefusal, + sequenceOutcome, + stepOnItsOwn, + readFollowOnRefusal, + cashoutSaid, +} from '../../lib/chains/sequence-flow' +import type { ChainActionRequest, ChainWithActions, WalletBalances } from '../../lib/chains/types' +import { QUOTE_EXPIRED } from '../../lib/chains/signing-flow' import { useChainAction } from '../../hooks/use-chain-action' +import { useChainSequence } from '../../hooks/use-chain-sequence' import { ChainActionConfirm } from './chain-action-confirm' +import { ChainSequenceSteps } from './chain-sequence-steps' import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout' import { CashoutCard } from './cashout-card' import { CashoutCurrencyQuestion, type CashoutQuestionState } from './cashout-currency-question' import { cashoutChoices, + CURRENCY_QUESTION, NO_CASHOUTS, unsupportedCurrencyReply, unsupportedTokenReply, @@ -191,7 +233,42 @@ function ChatIdentity(): JSX.Element { ) } -export function IntentChat(): JSX.Element { +/** What was said before this page was opened: words and a link, nothing to press or sign. */ +function SavedTurns({ turns }: { turns: LiveTurn[] }): JSX.Element { + return ( + <> + {turns.map((turn, i) => ( +
+
+ {turn.text} + {turn.explorerUrl !== undefined ? ( + + View transaction + + ) : null} +
+
+ ))} + + ) +} + +export function IntentChat({ + showing, + linkedRule, +}: { + /** False while the person is on another page and this is kept mounted behind it. */ + showing: boolean + linkedRule?: string +}): JSX.Element { const createIntent = useCreateIntent() const cancelIntent = useCancelIntent() const settleIntent = useSettleIntent() @@ -262,6 +339,10 @@ export function IntentChat(): JSX.Element { // Messages that were answered rather than run, with the replies given. Sent // with the next message so the model answers in the flow of the conversation. const dialogue = useRef([]) + // Where the chat is now, read after a slow answer to tell whether it is still wanted. + const latest = useRef({ chain: '', account: undefined as string | undefined, turn: 0 }) + // Clarify calls still waiting on an answer. The wait is over only when none are. + const clarifyRuns = useRef(0) function openInbox(): void { const items = inbox.items @@ -274,10 +355,12 @@ export function IntentChat(): JSX.Element { inbox.markSeen(items.map((i) => i.id)) } + // From the address, and read again when it changes: the chat stays mounted + // across pages, so a link can arrive long after it first rendered. useEffect(() => { - const id = new URLSearchParams(window.location.search).get('rule') - if (id !== null && id !== '') setLinkedRuleId(id) - }, []) + openedFromLink.current = false + setLinkedRuleId(linkedRule !== undefined && linkedRule !== '' ? linkedRule : null) + }, [linkedRule]) // Opens on the linked rule once the inbox has answered, and only once: a // user who closes it should not have it reopen on the next poll. @@ -322,8 +405,61 @@ export function IntentChat(): JSX.Element { const slippageEdit = strategy.slippageBps !== undefined ? { slippageBps: strategy.slippageBps } : {} - const { slug, descriptor } = useChain() - const { address, isConnected, balance: nativeBalance, balanceSymbol } = useWallet() + const { slug, segment, descriptor } = useChain() + const { address, isConnected } = useWallet() + latest.current.chain = slug + latest.current.account = address + + // What was said here before a reload. Read while no thread is open, which is + // also what keeps a wallet's own words from showing twice, and shown under the + // account it was read for only. Writing it never touches saveTurn. + const [savedTurns, setSavedTurns] = useState<{ account: string; turns: LiveTurn[] } | null>(null) + const transcriptOwner = isConnected ? address : undefined + // Whose chat is on screen, which is who it is recorded for. Differs from the + // connected wallet only while a switch waits on an open signature. + const chatOwner = useRef(transcriptOwner) + const transcript = restored === null ? ownedTurns(savedTurns, transcriptOwner) : [] + // Earlier turns and notes kept for the agents, already shown among the saved turns. + const [inSaved, setInSaved] = useState({ thread: 0, notes: 0 }) + useEffect(() => { + setSavedTurns( + transcriptOwner !== undefined && message === null && thread.length === 0 + ? { + account: transcriptOwner, + turns: loadLiveSession(liveSessionStorage(), segment, transcriptOwner), + } + : null + ) + // Only a new wallet or chain reads again; a thread opening must not. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segment, transcriptOwner]) + + function recordLive( + role: LiveTurn['role'], + text: string, + explorerUrl?: string, + account: string | undefined = chatOwner.current + ): void { + if (account === undefined) return + appendLiveTurn(liveSessionStorage(), segment, account, { + role, + text, + at: Date.now(), + ...(explorerUrl !== undefined ? { explorerUrl } : {}), + }) + } + + // One line per transaction, however many times its state is rendered or reported. + const recordedHashes = useRef(new Set()) + function recordSettled( + text: string, + hash: string | undefined, + explorerUrl: string | undefined + ): void { + if (hash === undefined || recordedHashes.current.has(hash)) return + recordedHashes.current.add(hash) + recordLive('agent', text, explorerUrl) + } // Checked once the wallet that owns it is known — historyOwner, which // pendingCashOut reads, is set from this same address elsewhere — so a @@ -512,6 +648,8 @@ export function IntentChat(): JSX.Element { }) useEffect(() => { if (chainRace === null || restored !== null || fellBackRef.current === chainRace) return + // The swap this race was for has settled, so nothing more is built from it. + if (chainSequence.settled) return if (live.phase === 'competing') startedRef.current = chainRace if (startedRef.current !== chainRace) return if (live.error?.code === 'action_refused') { @@ -528,6 +666,20 @@ export function IntentChat(): JSX.Element { }, [chainRace, raceEnded, live.error, live.phase, restored]) const cashout = useCashout() const [cashoutQuestion, setCashoutQuestion] = useState(null) + // A swap and what follows it, on a chain with its own module. Step 1 is the race above. + const chainSequence = useChainSequence(chainAction, cashout) + // Read when a model's answer comes back, which may be after the run has moved on. + const chainSequenceNow = useRef(chainSequence) + chainSequenceNow.current = chainSequence + // What a new message left behind by ending a swap that had settled. + const [sequenceNote, setSequenceNote] = useState(null) + useEffect(() => { + chainSequence.clear() + setSequenceNote(null) + // A question asked on the last chain can't start anything on this one. + setCashoutQuestion(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) // A leveraged position on Noether. Its own shape again: no route, no // competition, and a sign-in with the venue before anything is built. const perp = usePerp() @@ -748,6 +900,112 @@ export function IntentChat(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [decided, turnId, message, slug]) + // The transcript, as things are said and settle. Words and links only: a label + // or a card's figures come from what is signed, so none of them are kept. + useEffect(() => { + if ( + affordError !== null && + message !== null && + restored === null && + recordableReply(affordError) + ) { + recordLive('agent', affordError) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [affordError]) + + // A chain race leaves one sentence, below, and none of its agents' reasoning. + const raceWinner = liveCompetition.winner + const raceReply = + restored !== null || chainRace !== null + ? undefined + : decided && raceWinner !== null + ? liveCompetition.proposals[raceWinner]?.reasoning + : live.error?.message + useEffect(() => { + if (raceReply !== undefined && raceReply !== '' && message !== null) { + recordLive('agent', raceReply) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raceReply]) + + const typedStep = + chainSequence.sequence === null ? undefined : stepOnItsOwn(chainSequence.sequence) + const chainOutcome = chainOutcomeSentence({ + phase: chainAction.phase, + venueName: chainAction.prepared?.venueName, + error: stepErrorMessage(chainAction.error, chainSequence.settled, typedStep), + }) + useEffect(() => { + if (chainOutcome === undefined || message === null) return + // To the wallet that signed it, should another have connected since. + recordLive( + 'agent', + chainOutcome, + chainAction.phase === 'settled' ? chainAction.explorerUrl : undefined, + chainAction.account ?? chatOwner.current + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainOutcome]) + + const sequenceLine = sequenceOutcome( + chainSequence.stage, + chainSequence.delivered, + chainSequence.error, + chainSequence.sequence ?? undefined + ) + // Leaving the form brings `ready` back, and what arrived was said the first time. + const deliveredRecordedFor = useRef(null) + useEffect(() => { + if (sequenceLine === undefined) return + if (chainSequence.stage === 'ready') { + if (deliveredRecordedFor.current === chainSequence.sequence) return + deliveredRecordedFor.current = chainSequence.sequence + } + recordLive( + 'agent', + sequenceLine, + undefined, + chainSequence.account ?? chainAction.account ?? chatOwner.current + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequenceLine]) + + useEffect(() => { + recordSettled('The swap settled.', settledHash, swap.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settledHash]) + useEffect(() => { + const total = sequence.steps.length + sequence.steps.forEach((step, i) => { + recordSettled( + total === 1 ? 'The transaction settled.' : `Step ${i + 1} of ${total} settled.`, + step.hash, + step.explorerUrl + ) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequenceSteps]) + const supplyHash = supply.phase === 'settled' ? supply.hash : undefined + useEffect(() => { + recordSettled('The supply settled.', supplyHash, supply.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [supplyHash]) + const perpHash = perp.phase === 'settled' ? perp.hash : undefined + useEffect(() => { + recordSettled('The position opened.', perpHash, perp.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [perpHash]) + useEffect(() => { + recordSettled('The cash-out payment was sent.', cashout.hash, cashout.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cashout.hash]) + + /** A settled Stellar transaction known only by its hash. */ + function recordStellarTx(text: string, hash: string | undefined): void { + recordSettled(text, hash, `${stellarNetwork.blockExplorerUrl}/tx/${hash}`) + } + // The USDC the swap delivered is read from the account, so the balance is // refreshed the moment the swap settles. const swapSettled = swap.phase === 'settled' @@ -797,6 +1055,96 @@ export function IntentChat(): JSX.Element { sequence.phase === 'signing' || sequence.phase === 'submitting' || chainSigning(chainAction.phase) + + // Declared after the effects that record, so a transaction settling in the same + // render as a switch is written to the wallet that signed it, before the reset. + // A sequence with a step settled and the next unsigned counts as open: resetting + // would lose the only way to finish it. So does a chain swap still being measured. + // Off Stellar the wait ends when the signature does, or the measure: the reset below + // then ends the run, whether the swap just settled or reached `ready`. The new wallet + // is offered no step 2, and the outcome goes to the transcript of the wallet that signed. + const signatureOpen = + executionInFlight || + cashout.locked || + chainSequence.stage === 'measuring' || + (sequence.phase !== 'settled' && sequence.steps.some((step) => step.hash !== undefined)) + useEffect(() => { + const change = ownerChange(chatOwner.current, transcriptOwner, signatureOpen) + if (change === 'defer') { + setAffordError(WALLET_SWITCHED_NOTICE) + return + } + if (change === 'keep') { + if (transcriptOwner !== undefined) chatOwner.current = transcriptOwner + setAffordError((e) => (e === WALLET_SWITCHED_NOTICE ? null : e)) + return + } + leaveSequence() + clearOpenChat() + // None of what follows is cleared by New chat, and all of it is the last wallet's. + dialogue.current = [] + setPending(null) + supply.reset() + perp.reset() + planExec.reset() + limit.reset() + chainAction.reset() + setHistoryOpen(false) + setRulesOpen(false) + setInboxOpen(false) + chatOwner.current = transcriptOwner + setSavedTurns( + transcriptOwner === undefined + ? null + : { + account: transcriptOwner, + turns: loadLiveSession(liveSessionStorage(), segment, transcriptOwner), + } + ) + // Runs on a new wallet, and again when the signature it was waiting on ends. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transcriptOwner, signatureOpen]) + + // Coming back to a card left open. A chain's card or a position holds what was + // built when it was shown, and not every one is refused later: NEAR's wallet sends + // for itself with no check of the deadline. A Stellar swap is rebuilt and aged by + // the server at signing, and its other envelopes lapse on the network, so neither + // is touched here. + const chainCardShownAt = useRef(0) + useEffect(() => { + chainCardShownAt.current = Date.now() + }, [chainAction.prepared]) + const perpCardShownAt = useRef(0) + useEffect(() => { + perpCardShownAt.current = Date.now() + }, [perp.xdr]) + const wasShowing = useRef(showing) + useEffect(() => { + const cameBack = showing && !wasShowing.current + wasShowing.current = showing + if (!cameBack) return + const now = Date.now() + const deadline = chainAction.prepared?.context?.['deadline'] + if ( + chainAction.phase === 'review' && + cardIsStale( + { + ...(deadline !== undefined ? { deadline } : {}), + shownAt: chainCardShownAt.current, + }, + now + ) + ) { + chainAction.refuse(QUOTE_EXPIRED, chainAction.asked ?? message ?? '') + } + if (perp.phase === 'review' && cardIsStale({ shownAt: perpCardShownAt.current }, now)) { + perp.reset() + setAffordError(QUOTE_EXPIRED) + } + // Only the moment of coming back decides this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showing]) + /** Something came of the open intent: a trade settled, or an order was placed. */ const turnExecuted = swap.phase === 'settled' || sequence.phase === 'settled' || placedId !== null @@ -906,6 +1254,7 @@ export function IntentChat(): JSX.Element { setMessage(text) setCashOutFrom({ hash: stranded.swapHash, receive: stranded.receiveDisplay }) setStrandedCashOut(undefined) + recordLive('user', text) saveTurn({ id: newTurnId, chain: slug, @@ -938,7 +1287,7 @@ export function IntentChat(): JSX.Element { if (!continuing && message !== null && parsed !== null && restored === null && !turnExecuted) { if (classifyFollowUp(text) === 'note') { if (executionInFlight) { - setAffordError('Finish or cancel the signature first, then continue the chat.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } setNotes((n) => [...n, text]) @@ -1052,6 +1401,7 @@ export function IntentChat(): JSX.Element { const mentionsBase = /(xlm|usdc)/i.test(text) if (other !== undefined && (source.kind === 'other' || !mentionsBase)) { const wanted = other.symbol + const wantedBy = latest.current.account void (async () => { let found: VerifiedAsset | undefined try { @@ -1061,6 +1411,7 @@ export function IntentChat(): JSX.Element { } catch { found = undefined } + if (wantedBy !== latest.current.account) return if (found === undefined) { setAffordError( `I can't confirm ${wanted} as a verified token on Stellar, so I won't cash it out. Which token do you mean?` @@ -1211,6 +1562,7 @@ export function IntentChat(): JSX.Element { // ways of writing one instruction it understood five, and the misses split // evenly between an unmatched marker and an unlisted verb. Widening either // list closes those and fails on the next seven. + const readFor = latest.current.account void (async () => { setParsing(true) let read: UnderstoodIntent | null = null @@ -1227,6 +1579,7 @@ export function IntentChat(): JSX.Element { body: JSON.stringify({ text, chain: slug }), }) ).json() + if (readFor !== latest.current.account) return const body = raw as { understood?: boolean action?: 'swap' | 'supply' | 'borrow' | 'repay' | 'offramp' @@ -1397,10 +1750,173 @@ export function IntentChat(): JSX.Element { })() } + /** Ends a chain sequence where it stands. Its USDC stays the person's, and they are told so. */ + function leaveSequence(): void { + const run = chainSequenceNow.current + const note = endedNote(run.stage, run.delivered, run.sequence) + setSequenceNote(note ?? null) + if (note !== undefined) { + recordLive('agent', note, undefined, run.account ?? chatOwner.current) + } + run.clear() + } + + /** Step 2's press. A model's answer still on its way must not start anything under it. */ + function pressNextStep(): void { + latest.current.turn += 1 + chainSequence.runNext() + } + + /** Step 1: the same race as a plain swap, with the sequence watching its card. */ + function runSequenceSwap(request: ChainSequenceRequest, text: string): void { + // Sized here and not on the way in, so a currency question left open is priced when answered. + if (request.swap.amountIsUsd === true) { + sizeThen(request.swap, text, (swap) => runSequenceSwap({ ...request, swap }, text)) + return + } + setAffordError(null) + // A new race is the one thing that lifts the "already signed" latch. + chainAction.release() + chainAction.reset() + chainSequence.start(request, text) + setChainRace({ request: request.swap, text }) + } + + /** + * "$5 of SOL" as a count of SOL at a price read now, and says so. The two places a race or a card + * starts come through here first, so a figure in dollars can't reach one as that many tokens. + */ + function sizeThen( + request: ChainActionRequest, + text: string, + go: (sized: ChainActionRequest) => void + ): void { + setMessage(text) + setClarifying(null) + setAffordError(null) + clearChainRace() + chainAction.reset() + setClarifyBusy(true) + const { turn, account, chain } = latest.current + void sizeInUnits(slug, request).then((out) => { + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The person has moved on, and nothing must start under what they did next. + const now = latest.current + if (turn !== now.turn || account !== now.account || chain !== now.chain) return + if ('refusal' in out) { + chainAction.refuse(out.refusal, text) + return + } + go(out.request) + const { line } = out + if (line !== undefined) { + // Added to, not over, a note on what a run left behind, such as USDC still in the wallet. + setSequenceNote((shown) => (shown === null ? line : `${shown} ${line}`)) + recordLive('agent', line) + } + }) + } + + /** A plain chain action, typed or read by the orchestrator: the race, or the card for a send. */ + function startChainAction(action: ChainActionRequest, text: string): void { + if (action.amountIsUsd === true) { + sizeThen(action, text, (sized) => startChainAction(sized, text)) + return + } + // The thread only renders once there is a message, so without this the + // card below would be built and never seen. + setMessage(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) + setClarifying(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() + if (racesFirst(action)) { + setChainRace({ request: action, text }) + } else { + // Nothing to price or choose, so a stale race is cleared and the card comes straight up. + clearChainRace() + chainAction.prepare(action, text) + } + } + + /** A swap and what follows it. Whatever could not finish is refused here, before step 1. */ + function startChainSequence(request: ChainSequenceRequest, text: string): void { + const capabilities = chainCapabilities(slug) + if (capabilities === undefined) return + const { followOn } = request + setMessage(text) + resetTurnState() + setParsed(null) + setTurnId(null) + setClarifying(null) + const refusal = + (followOn.kind === 'cashout' && cashout.locked ? CASHOUT_UNDER_WAY : undefined) ?? + followOnRefusal( + slug as ChainWithActions, + descriptor.name, + followOn, + capabilities.map((c) => ({ kinds: [c.kind] })), + request.swap.tokenOut + ) + if (refusal !== undefined) { + chainAction.refuse(refusal, text) + return + } + if (followOn.kind !== 'cashout') { + if (address === undefined) { + chainAction.refuse('Connect a wallet first.', text) + return + } + // Asked of the server before step 1, since a step that can't run is no use after it. + chainAction.reset() + setClarifyBusy(true) + const { turn, account, chain } = latest.current + void readFollowOnRefusal(slug, request, address).then((refused) => { + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The person has moved on, and a swap must not start under what they did next. + const now = latest.current + if (turn !== now.turn || account !== now.account || chain !== now.chain) return + if (refused !== undefined) chainAction.refuse(refused, text) + else runSequenceSwap(request, text) + }) + return + } + // Asked of Weave before step 1, since a currency it can't pay is no use after it. + cashout.dismiss() + chainAction.reset() + setClarifyBusy(true) + const { turn, account, chain } = latest.current + void fetchCashoutCurrencies(slug).then((weave) => { + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The person has moved on, and a swap must not start under what they did next. + const now = latest.current + if (turn !== now.turn || account !== now.account || chain !== now.chain) return + const step = currencyStep(followOn.currency, cashoutChoices(slug, weave)) + if (step.kind === 'refuse') { + chainAction.refuse(step.message, text) + } else if (step.kind === 'ask') { + setCashoutQuestion({ choices: step.choices, sequence: { request, chain } }) + recordLive('agent', CURRENCY_QUESTION) + } else { + runSequenceSwap( + { ...request, followOn: { kind: 'cashout', currency: step.currency } }, + text + ) + } + }) + } + /** A cash-out read from the composer: the card, or a plain answer about what it can't do. */ function startCashout(route: Exclude, text: string): void { if (executionInFlight) { - setAffordError('Finish or cancel the signature first, then continue the chat.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } setMessage(text) @@ -1425,6 +1941,11 @@ export function IntentChat(): JSX.Element { supply.reset() perp.reset() + if (route.kind === 'sequence') { + startChainSequence(cashoutSequence(route), text) + return + } + if (route.kind === 'ask') { if (cashout.locked) { chainAction.refuse( @@ -1436,8 +1957,11 @@ export function IntentChat(): JSX.Element { cashout.dismiss() chainAction.reset() setClarifyBusy(true) + const asked = latest.current.turn + const askedFor = latest.current.account void fetchCashoutCurrencies(slug).then((weave) => { setClarifyBusy(false) + if (askedFor !== latest.current.account) return const choices = cashoutChoices(slug, weave) const only = choices.length === 1 ? choices[0] : undefined if (choices.length === 0) { @@ -1450,6 +1974,7 @@ export function IntentChat(): JSX.Element { choices, ...(route.amount !== undefined ? { amount: route.amount } : {}), }) + if (asked === latest.current.turn) recordLive('agent', CURRENCY_QUESTION) } }) return @@ -1478,10 +2003,12 @@ export function IntentChat(): JSX.Element { cashout.dismiss() if (route.kind === 'token') { - chainAction.refuse(unsupportedTokenReply(route.token), text) + chainAction.refuse(unsupportedTokenReply(route.token, slug), text) return } + const refusedFor = latest.current.account void fetchCashoutCurrencies(slug).then((currencies) => { + if (refusedFor !== latest.current.account) return chainAction.refuse(unsupportedCurrencyReply(route.currency, currencies), text) }) } @@ -1515,10 +2042,38 @@ export function IntentChat(): JSX.Element { }) } + /** A currency named in answer to the question: used if offered, and said back if not. */ + function answerNamedCurrency(named: string, text: string): void { + if (cashoutQuestion === null) return + const offered = cashoutQuestion.choices + if (offered.some((choice) => choice.code === named)) { + answerCashoutQuestion(named, text) + } else { + const reply = unsupportedCurrencyReply(named, offered) + setCashoutQuestion({ ...cashoutQuestion, retry: { said: text, reply } }) + recordLive('agent', reply) + } + } + function answerCashoutQuestion(currency: string, said: string): void { const asked = cashoutQuestion if (asked === null) return setCashoutQuestion({ ...asked, answer: said }) + if (asked.sequence !== undefined) { + const elsewhere = questionChainRefusal(asked.sequence.chain, slug) + if (elsewhere !== undefined) { + chainAction.refuse(elsewhere, message ?? said) + return + } + runSequenceSwap( + { + ...asked.sequence.request, + followOn: { kind: 'cashout', currency: currency as CashoutCurrencyCode }, + }, + message ?? said + ) + return + } payOutIn(currency, asked.amount, message ?? said) } @@ -1532,16 +2087,46 @@ export function IntentChat(): JSX.Element { * own words run, so asking never makes the chat less capable than before. */ function handleSubmit(typed: string): void { + latest.current.turn += 1 // 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.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet()) + const capabilities = chainCapabilities(slug) + const chainRead = + capabilities === undefined + ? null + : readChainMessage(text, slug as ChainWithActions, descriptor.name) + const open = message !== null && parsed !== null && restored === null && !turnExecuted + + // Kept as it is shown. Two paths turn it away under a signature and never show it. + const refused = + executionInFlight && + (cashoutRoute.kind !== 'pass' || + (clarifying === null && open && classifyFollowUp(text) === 'note')) + if (!refused) { + recordLive('user', text) + if (keepsRun(chainSequence.stage, messageStartsAction(cashoutRoute, chainRead))) { + setSequenceNote(null) + } else { + leaveSequence() + } + } + + // "cash out to KES" at the question of a swap waiting on it answers that question, + // where read as a request it would drop the swap. + const routedAnswer = questionAnswerFrom(cashoutQuestion, cashoutRoute) + if (routedAnswer !== undefined) { + answerNamedCurrency(routedAnswer, text) + return + } + if (cashoutRoute.kind !== 'pass') { startCashout(cashoutRoute, text) return @@ -1552,16 +2137,7 @@ export function IntentChat(): JSX.Element { if (cashoutQuestion !== null && cashoutQuestion.answer === undefined) { const answer = parseCurrencyAnswer(text) if (answer !== null) { - const offered = cashoutQuestion.choices - const named = 'currency' in answer ? answer.currency : answer.unsupported - if (offered.some((choice) => choice.code === named)) { - answerCashoutQuestion(named, text) - } else { - setCashoutQuestion({ - ...cashoutQuestion, - retry: { said: text, reply: unsupportedCurrencyReply(named, offered) }, - }) - } + answerNamedCurrency('currency' in answer ? answer.currency : answer.unsupported, text) return } } @@ -1571,34 +2147,29 @@ export function IntentChat(): JSX.Element { if (chainCapabilities(slug) === undefined) chainAction.reset() // 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) - if (capabilities !== undefined) { - const action = parseChainAction(text) - if (action !== null) { - // The thread only renders once there is a message, so without this the - // card below would be built and never seen. + // below are Stellar's. What the strict parsers can't read in full goes to the + // orchestrator, verbs or not, and `readChainMessage` is what decides that. + if (chainRead !== null) { + if (chainRead.kind === 'refusal') { + setClarifying(null) setMessage(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 }) + clearChainRace() + chainAction.refuse(chainRead.refusal, text) return } - if (looksLikeChainAction(text)) { - setMessage(text) - clearChainRace() - chainAction.refuse(chainHelp(descriptor.name, capabilities), text) + if (chainRead.kind === 'sequence') { + startChainSequence(chainRead.request, text) return } + if (chainRead.kind === 'cashout') { + startCashout(chainRead.route, text) + return + } + if (chainRead.kind === 'action') { + startChainAction(chainRead.action, text) + return + } + // A step 2 card still unsigned goes too, and the run's button is what brings it back. chainAction.reset() clearChainRace() } @@ -1610,7 +2181,6 @@ export function IntentChat(): JSX.Element { } // A note on an open intent refines it and needs no question of its own. - const open = message !== null && parsed !== null && restored === null && !turnExecuted if (open && classifyFollowUp(text) === 'note') { void refine(text) return @@ -1633,7 +2203,7 @@ export function IntentChat(): JSX.Element { */ async function refine(note: string): Promise { if (executionInFlight) { - setAffordError('Finish or cancel the signature first, then continue the chat.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } await clarify( @@ -1646,11 +2216,24 @@ export function IntentChat(): JSX.Element { ) } - /** What the wallet holds, from balances already read: Stellar lists every asset, other chains their native one. */ - function walletBalanceReply(): string { + /** What the wallet holds. Stellar lists every asset from balances already read; other chains are read from the chain now. */ + async function walletBalanceReply(): Promise { if (slug === 'stellar') return balanceMessage(isConnected ? balances : undefined) - if (!isConnected || nativeBalance === undefined) return balanceMessage(undefined) - return `Your wallet holds:${String.fromCharCode(10)}• ${nativeBalance} ${balanceSymbol}` + if (!isConnected || address === undefined) return balanceMessage(undefined) + let held: WalletBalances | undefined + try { + const response = await fetch( + `/api/chain/${slug}/balances?account=${encodeURIComponent(address)}`, + { cache: 'no-store', signal: AbortSignal.timeout(20_000) } + ) + if (response.ok) { + const body = (await response.json()) as Partial + if (Array.isArray(body.tokens)) held = body as WalletBalances + } + } catch { + held = undefined + } + return chainBalanceReply(held, descriptor.name) } async function clarify( @@ -1669,6 +2252,9 @@ export function IntentChat(): JSX.Element { setMessage(text) setTurnId(crypto.randomUUID()) } + latest.current.turn += 1 + const asked = { ...latest.current } + clarifyRuns.current += 1 setClarifying({ turns }) setClarifyBusy(true) @@ -1677,15 +2263,51 @@ export function IntentChat(): JSX.Element { // The conversation so far, replies included. Without it every message is // read cold, and "explain that better" has nothing to improve on. const answer = await fetchOrchestrate(turns, earlier, slug, fetch, dialogue.current) - setClarifyBusy(false) + // The busy indicator stays up until the holdings are read. A reply that arrives after the + // person moved to another wallet, chain or message is not theirs any more, so it is dropped. + const balanceText = answer.outcome === 'balance' ? await walletBalanceReply() : undefined + clarifyRuns.current -= 1 + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The last wallet's question, answered after it left. A balance has its own rule below. + if (balanceText === undefined && asked.account !== latest.current.account) return + const decision = + balanceText === undefined + ? { kind: 'show' as const } + : balanceReplyDecision(asked, latest.current) + if (decision.kind === 'drop') return + if (decision.kind === 'notice') { + setClarifying(null) + setAffordError(decision.text) + return + } if (answer.outcome === 'ask') { setClarifying({ turns: [...turns, { role: 'assistant', content: answer.question }] }) + if (asked.turn === latest.current.turn) recordLive('agent', answer.question) return } setClarifying(null) + const chainCaps = chainCapabilities(slug) + const said = turns.filter((turn) => turn.role === 'user').map((turn) => turn.content) + + // No model read this. Why, or what works here if it asks for something, is said in its place. + if ( + answer.outcome === 'unavailable' && + chainCaps !== undefined && + asked.turn === latest.current.turn + ) { + const help = helpForUnread(answer.reason, said, descriptor.name, chainCaps) + if (help !== undefined) { + setParsed(null) + setFollowOn(null) + clearChainRace() + chainAction.refuse(help, turns[0]?.content ?? text) + return + } + } + // Nothing runs: the message is not an instruction, or could not be read. // The reply is the model's own, written for this message in this // conversation. Fixed text is only what is shown when there was no model to @@ -1697,7 +2319,7 @@ export function IntentChat(): JSX.Element { answer.outcome === 'help' ? (answer.reply ?? helpMessage(slug)) : answer.outcome === 'balance' - ? walletBalanceReply() + ? (balanceText as string) : answer.outcome === 'out_of_scope' ? outOfScopeMessage(slug, answer.reply) : answer.outcome === 'unclear' @@ -1728,10 +2350,47 @@ export function IntentChat(): JSX.Element { // conversation the next message belongs to. dialogue.current = [] + // Off Stellar nothing may start under whatever was said or pressed while this was being read. + if ( + chainCaps !== undefined && + (asked.turn !== latest.current.turn || asked.chain !== latest.current.chain) + ) { + return + } + // The model wrote out a cash-out, perhaps from words no parser here would read. const writtenOut = routeCashout(parseCashout(answer.instruction), slug, isMainnet()) if (writtenOut.kind !== 'pass') { - startCashout(writtenOut, turns[0]?.content ?? text) + const shown = turns[0]?.content ?? text + // What gets signed comes from the person's own words, never from the model's. + if (writtenOut.kind === 'sequence' && !cashoutSaid(writtenOut, said)) { + startCashout({ kind: 'token', token: writtenOut.token }, shown) + return + } + if (chainCaps !== undefined && cashoutStarts(writtenOut)) leaveSequence() + startCashout(writtenOut, shown) + return + } + + // Off Stellar a reading never reaches Stellar's swap and lend flows below. It goes where a typed + // sentence goes, and only what the person typed themselves can be signed. + if (chainCaps !== undefined) { + const shown = turns[0]?.content ?? text + const route = chainRouteFromReading(answer.read, answer.instruction, said) + if (route !== null && 'sequence' in route) { + leaveSequence() + startChainSequence(route.sequence, shown) + } else if (route !== null && 'action' in route) { + leaveSequence() + startChainAction(route.action, shown) + } else { + setMessage(shown) + clearChainRace() + chainAction.refuse( + route !== null ? route.refusal : chainHelp(descriptor.name, chainCaps), + shown + ) + } return } @@ -1791,9 +2450,20 @@ export function IntentChat(): JSX.Element { /** A clean slate: the open intent, the turns before it and any notes. */ function newChat(): void { + // While a switch waits on a signature the chat on screen is neither wallet's to clear. + if (transcriptOwner !== undefined && transcriptOwner === chatOwner.current) { + clearLiveSession(liveSessionStorage(), segment, transcriptOwner) + } + clearOpenChat() + setSavedTurns(null) + } + + function clearOpenChat(): void { + latest.current.turn += 1 handleReset() setThread([]) setNotes([]) + setInSaved({ thread: 0, notes: 0 }) setClarifying(null) setClarifyBusy(false) setCashoutQuestion(null) @@ -1804,6 +2474,9 @@ 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 + latest.current.turn += 1 + chainSequence.clear() + setSequenceNote(null) 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) @@ -1826,6 +2499,15 @@ export function IntentChat(): JSX.Element { // read as a stuck transaction that had never existed. setFollowOn(null) sequence.reset() + // The turns so far stay on screen as a reload would show them. The thread and + // notes behind them still go to the agents, so they are hidden, not dropped. + const owner = chatOwner.current + if (owner === undefined) return + const turns = loadLiveSession(liveSessionStorage(), segment, owner) + setSavedTurns({ account: owner, turns }) + setInSaved( + turns.length > 0 ? { thread: thread.length, notes: notes.length } : { thread: 0, notes: 0 } + ) } /** @@ -1868,7 +2550,7 @@ export function IntentChat(): JSX.Element { asked: chainRace.request, text: chainRace.text, phase: chainAction.phase, - signed: chainAction.signed === true, + signed: raceClosed(chainAction.signed === true, chainSequence.settled), preparedVenueId: chainAction.prepared?.venueId, ...(live.competitionId !== undefined ? { raceId: live.competitionId } : {}), prepare: chainAction.prepare, @@ -2278,6 +2960,9 @@ export function IntentChat(): JSX.Element { turns={listedTurns} onSelect={(turn) => { if (chainSigning(chainAction.phase)) return + leaveSequence() + // Off Stellar, a price or check still on its way must not start anything over this. + if (chainCapabilities(slug) !== undefined) latest.current.turn += 1 // A race on screen ends when another turn is opened. if (chainRace !== null) { clearChainRace() @@ -2330,7 +3015,14 @@ export function IntentChat(): JSX.Element { Even the error message lived in this dead branch, so a failure was as invisible as a success. */} {!message ? ( -
+
0 + ? 'flex flex-col gap-4' + : 'flex h-full flex-col items-center justify-center gap-4 text-center' + } + > + {/* A swap from an earlier session settled meaning to be cashed out, and the tab closed before that second step started. The swap itself is already safe in history; this is only @@ -2361,22 +3053,27 @@ export function IntentChat(): JSX.Element {
) : null} - - - -

- Say what you want to happen. Autonomous agents compete to find the best execution — - you pick who executes. -

+ {transcript.length === 0 ? ( + <> + + + +

+ Say what you want to happen. Autonomous agents compete to find the best + execution — you pick who executes. +

+ + ) : null}
) : (
+ {/* A reopened conversation shows the prices that were quoted when it ran. Saying so matters: signing against a rate from an hour ago is a different decision from signing a fresh one. */} @@ -2401,7 +3098,7 @@ export function IntentChat(): JSX.Element { ) : null} {/* The turns before this one, quieter than the open intent. */} - {thread.map((turn) => ( + {thread.slice(inSaved.thread).map((turn) => (
{turn.text}
@@ -2479,7 +3176,7 @@ export function IntentChat(): JSX.Element { ) : null} {/* What was added to the open intent while it was being decided. */} - {notes.map((note, i) => ( + {notes.slice(inSaved.notes).map((note, i) => (
{note}
@@ -2501,12 +3198,19 @@ export function IntentChat(): JSX.Element { ) ) : null} + {sequenceNote !== null && restored === null ? ( +
+
{sequenceNote}
+
+ ) : null} {cashoutQuestion !== null ? ( - answerCashoutQuestion(choice.code, `${choice.name} (${choice.code})`) - } + onChoose={(choice) => { + const said = `${choice.name} (${choice.code})` + recordLive('user', said) + answerCashoutQuestion(choice.code, said) + }} /> ) : null} {clarifyBusy ? ( @@ -2523,10 +3227,13 @@ export function IntentChat(): JSX.Element { swap.phase === 'signing' || swap.phase === 'submitting' || swap.phase === 'settled' || - chainReviewClosed(chainAction.phase, chainAction.signed === true) + chainReviewClosed( + chainAction.phase, + raceClosed(chainAction.signed === true, chainSequence.settled) + ) } directRoute={directFallback} - signed={chainAction.requested} + signed={raceClosed(chainAction.requested, chainSequence.settled)} {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once @@ -2593,6 +3300,7 @@ export function IntentChat(): JSX.Element { ? { balanceUsdc: balances.trustlines['USDC'].balance } : {})} onOrder={(order) => { + recordStellarTx('The cash-out payment was sent.', order.paymentHash) if (turnId === null) return updateTurn( turnId, @@ -2702,14 +3410,22 @@ export function IntentChat(): JSX.Element { one signature — no sequence to explain. */} {supply.phase !== 'idle' ? : null} - {showDirectNote(directFallback, chainAction.phase) ? ( + {restored === null ? ( + + ) : null} + + {showDirectNote(directFallback, chainAction.phase, chainSequence.settled) ? (

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

) : null} {chainAction.phase !== 'idle' ? (
- +
) : null} @@ -2727,6 +3443,7 @@ export function IntentChat(): JSX.Element { { + recordStellarTx('The plan settled.', hash) if (turnId !== null) { updateTurn(turnId, { txHash: hash }) setTurns(loadTurns(slug)) @@ -2741,6 +3458,7 @@ export function IntentChat(): JSX.Element { sellSymbol={parsed.input.tokenIn} buySymbol={parsed.input.tokenOut} onPlaced={(hash) => { + recordStellarTx('The order was placed.', hash) // The placing transaction is a real, verifiable event, but it // is not a fill — the order is only now waiting. Recording it // on the conversation lets history link to it without @@ -2844,6 +3562,7 @@ export function IntentChat(): JSX.Element { // the reminder below exists to chase is done, whatever // happens to the order from here. clearPendingCashOut() + recordStellarTx('The cash-out payment was sent.', order.paymentHash) if (turnId === null) return updateTurn( turnId, @@ -2872,7 +3591,7 @@ export function IntentChat(): JSX.Element { > { - setState({ phase: 'building', asked }) + setState({ phase: 'building', asked, building: request.kind }) const res = await fetch(`/api/chain/${slug}/prepare`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/dapp/hooks/use-chain-sequence.ts b/apps/dapp/hooks/use-chain-sequence.ts new file mode 100644 index 00000000..fe00f49e --- /dev/null +++ b/apps/dapp/hooks/use-chain-sequence.ts @@ -0,0 +1,311 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { ChainSequenceRequest } from '../lib/chains/sequence' +import { + beforeReadCounts, + keepsBefore, + measureDelivered, + moveOnCard, + moveOnSecond, + nextRequest, + outputToken, + quoteStep, + readBeforeSwap, + readCashoutNote, + readHolding, + readyAction, + secondStepPress, + stageOnForm, + stepAmount, + stepLabels, + swapLine, + unmeasuredReply, + type Before, + type BeforeStatus, + type SequenceProgress, + type SequenceStage, +} from '../lib/chains/sequence-flow' +import type { ChainToken } from '../lib/chains/types' +import { useChain } from '../providers/chain-provider' +import type { ChainAction } from './use-chain-action' +import type { Cashout } from './use-cashout' +import { useWallet } from './use-wallet' + +interface Run extends SequenceProgress { + request: ChainSequenceRequest + delivered?: string + /** The wallet step 1 was built for, once it has settled. */ + account?: string + /** What step 1's card said would arrive, kept from when it settled. */ + quotedOut?: string +} + +const UNKNOWN: Before = { status: 'unknown' } + +export interface ChainSequence { + sequence: ChainSequenceRequest | null + stage: SequenceStage | undefined + /** What the chain shows arrived, in display units. Never a quote and never a guess. */ + delivered: string | undefined + error: string | undefined + settled: boolean + /** The wallet step 1 was built for, once it has settled. */ + account: string | undefined + /** What step 1's card said would arrive. */ + quotedOut: string | undefined + /** How the balance before the swap stands. */ + readStatus: BeforeStatus + /** What to say about step 2 while step 1 is unsigned. */ + swapLine: string + start: (request: ChainSequenceRequest, text: string) => void + /** Step 2. Called from a press and nowhere else. */ + runNext: () => void + clear: () => void +} + +/** + * A swap on a chain other than Stellar, and what follows it. It adds no signing and no latch: step + * 1 is the ordinary race and `chainAction` card, a new race is still what lifts that card's latch, + * and this watches them to learn what arrived. Reloading loses it, by design. Nothing signable is + * kept, and the transcript and the card's attempt notice cover the person. + * + * A deposit, stake or send is that same card again. The one other thing that lifts its latch is + * step 2's press, once, from `ready`, and `ready` is reached only from a settled step 1 or from a + * step 2 that proved nothing moved. See `moveOnSecond`. + */ +export function useChainSequence( + chainAction: ChainAction, + cashout: Pick +): ChainSequence { + const { slug } = useChain() + const { address } = useWallet() + const [run, setRun] = useState(null) + const [readStatus, setReadStatus] = useState('unknown') + const [quoted, setQuoted] = useState(undefined) + + // Read at the press, not the render the button was drawn in. + const connected = useRef(address) + connected.current = address + const { phase, signed, prepared, account: builtFor } = chainAction + const receiveAmount = prepared?.receive?.amount + // Read when a `before` answers, which is some way on from when it was asked. + const phaseNow = useRef(phase) + phaseNow.current = phase + const familyNow = useRef(prepared?.signing.family) + familyNow.current = prepared?.signing.family + + const before = useRef(UNKNOWN) + // Each read, start and clear takes a number, so an answer to an older one is dropped. + const beforeRead = useRef(0) + + const request = run?.request + const stage = run?.stage + // Past this, the card is step 2's, and what it shows has no bearing on a balance before the swap. + const pastSwap = run?.settled === true + + const noteBefore = useCallback((next: Before) => { + before.current = next + setReadStatus(next.status) + }, []) + + const readBefore = useCallback( + (account: string | undefined, token: ChainToken | undefined) => { + beforeRead.current += 1 + const mine = beforeRead.current + if (account === undefined || token === undefined) { + noteBefore(UNKNOWN) + return + } + const counts = (): boolean => + mine === beforeRead.current && beforeReadCounts(phaseNow.current, familyNow.current) + noteBefore({ status: 'reading', account, token }) + void readBeforeSwap(() => readHolding(slug, account, token), counts).then((amount) => { + if (mine !== beforeRead.current) return + noteBefore( + amount !== undefined && counts() ? { status: 'known', amount, account, token } : UNKNOWN + ) + }) + }, + [slug, noteBefore] + ) + + // From when the card starts building, which leaves a person no time to sign ahead of the read. + // The prepared card isn't there yet, so this goes by what was asked and who is connected, and + // the card is checked against that when it comes up. A wallet declined comes back to `review` + // and keeps what was read. + useEffect(() => { + if (request === undefined || pastSwap) return + const asked = request.swap.tokenOut + if (phase === 'building') { + readBefore(address, outputToken(slug, asked, asked)) + return + } + if (phase !== 'review' || prepared === undefined || builtFor === undefined) return + const token = outputToken(slug, prepared.receive?.symbol, asked) + if (token !== undefined && keepsBefore(before.current, builtFor, token.id)) return + readBefore(builtFor, token) + }, [request, pastSwap, phase, prepared, builtFor, address, slug, readBefore]) + + // Whether Weave would take what this card should deliver. Never holds the card back: it only + // changes what is said, and a failed or slow read says nothing new. + useEffect(() => { + setQuoted(undefined) + if (request === undefined || prepared === undefined) return + const { followOn } = request + if (followOn.kind !== 'cashout' || followOn.currency === undefined) return + const step = quoteStep(prepared.receive?.amount) + if (step.kind === 'none') return + if (step.kind === 'note') { + setQuoted(step.note) + return + } + let replaced = false + void readCashoutNote(slug, followOn.currency, step.amount).then((note) => { + if (!replaced) setQuoted(note) + }) + return () => { + replaced = true + } + }, [request, prepared, slug]) + + useEffect(() => { + setRun((r) => { + if (r === null) return r + if (r.stage === 'following') { + const second = moveOnSecond(r, phase, signed, r.request, r.delivered, prepared?.kind) + if (second === r) return r + const { error: _said, opened: _opened, ...rest } = r + return { ...rest, ...second } + } + const known = before.current + const moved = moveOnCard(r, phase, signed, known.status === 'known', r.request) + if (moved === r) return r + const { stage: nextStage, settled, error } = moved + return { + request: r.request, + stage: nextStage, + settled, + ...(error !== undefined ? { error } : {}), + ...(settled && known.status === 'known' ? { account: known.account } : {}), + ...(settled && receiveAmount !== undefined ? { quotedOut: receiveAmount } : {}), + } + }) + }, [phase, signed, prepared?.kind, receiveAmount]) + + // One read at a time, ending on unmount, on `clear` and at the limit. + const measuring = stage === 'measuring' + useEffect(() => { + if (!measuring) return + const known = before.current + if (known.status !== 'known') { + setRun((r) => + r === null ? r : { ...r, stage: 'stranded', error: unmeasuredReply(r.request) } + ) + return + } + const stop = new AbortController() + void measureDelivered({ + before: known.amount, + decimals: known.token.decimals, + read: (signal) => readHolding(slug, known.account, known.token, signal), + signal: stop.signal, + }).then((result) => { + if (result === undefined) return + setRun((r) => { + if (r?.stage !== 'measuring') return r + return result.stage === 'ready' + ? { ...r, stage: 'ready', delivered: result.delivered } + : { ...r, stage: 'stranded', error: unmeasuredReply(r.request) } + }) + }) + return () => stop.abort() + }, [measuring, slug]) + + const formOpen = cashout.session !== null + const { locked, hash: paidHash } = cashout + useEffect(() => { + setRun((r) => { + if (r === null) return r + const next = stageOnForm(r.stage, { + open: formOpen, + locked, + paid: paidHash !== undefined, + }) + return next === r.stage ? r : { ...r, stage: next } + }) + }, [formOpen, locked, paidHash]) + + const start = useCallback( + (next: ChainSequenceRequest) => { + beforeRead.current += 1 + noteBefore(UNKNOWN) + setRun({ request: next, stage: 'swap', settled: false }) + }, + [noteBefore] + ) + + const clear = useCallback(() => { + beforeRead.current += 1 + noteBefore(UNKNOWN) + setRun(null) + }, [noteBefore]) + + const runNext = useCallback(() => { + if (run?.stage !== 'ready' || run.delivered === undefined || run.account === undefined) return + const { followOn } = run.request + if (followOn.kind !== 'cashout') { + if (!run.settled) return + const asked = run.request.swap.tokenOut + const token = outputToken(slug, asked, asked) + const { amount } = stepAmount(run.request, run.delivered, run.quotedOut) + const press = secondStepPress( + token === undefined ? undefined : nextRequest(run.request, amount, token.decimals), + connected.current, + run.account + ) + if (press.kind === 'refused') { + setRun((r) => (r === null ? r : { ...r, error: press.message })) + return + } + setRun((r) => { + if (r === null) return r + const { error: _said, ...rest } = r + return { ...rest, stage: 'following', opened: false } + }) + chainAction.release() + chainAction.prepare(press.request, stepLabels(run.request)[1]) + return + } + const action = readyAction(run.delivered, cashout.locked, connected.current, run.account) + if (action.kind !== 'open') { + setRun((r) => (r === null ? r : { ...r, error: action.message })) + return + } + setRun((r) => { + if (r === null) return r + const { error: _said, ...rest } = r + return { ...rest, stage: 'next' } + }) + cashout.open({ + amount: action.amount, + ...(followOn.currency !== undefined ? { currency: followOn.currency } : {}), + }) + }, [run, cashout, chainAction, slug]) + + return { + sequence: run?.request ?? null, + stage, + delivered: run?.delivered, + error: run?.error, + settled: run?.settled ?? false, + account: run?.account, + quotedOut: run?.quotedOut, + readStatus, + swapLine: swapLine(phase, readStatus, quoted, request), + start, + runNext, + clear, + } +} diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index f4992c0d..118d057b 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -125,6 +125,23 @@ describe('POST /api/agents/compete on a chain', () => { expect(state.propose).not.toHaveBeenCalled() }) + it('refuses a crafted race for a send, which never races', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'send 5 USDC to bob.near', + chain: 'near', + account: 'a.near', + chainAction: { kind: 'send', amount: '5', tokenIn: 'USDC', recipient: 'bob.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( diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts index 98c64452..0ae401a3 100644 --- a/apps/dapp/lib/__tests__/agents-chain-race.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-race.test.ts @@ -4,6 +4,8 @@ import { chainFigures, chainRequestFor, chainReviewClosed, + raceClosed, + stepErrorMessage, chainSigning, isChainRoute, needsDirectRoute, @@ -237,6 +239,12 @@ describe('chain card phases', () => { expect(showDirectNote(false, 'review')).toBe(false) }) + it('never says nothing was signed above step 2, once the swap settled', () => { + expect(showDirectNote(true, 'review', false)).toBe(true) + expect(showDirectNote(true, 'review', true)).toBe(false) + expect(showDirectNote(true, 'building', true)).toBe(false) + }) + it('drops the selected look after a failed or empty card', () => { expect(selectionHolds('failed')).toBe(false) expect(selectionHolds('idle')).toBe(false) @@ -246,6 +254,88 @@ describe('chain card phases', () => { }) }) +describe('raceClosed', () => { + it('closes on a signature, and on a settled sequence even with the latch lifted', () => { + expect(raceClosed(false, false)).toBe(false) + expect(raceClosed(true, false)).toBe(true) + expect(raceClosed(false, true)).toBe(true) + expect(raceClosed(true, true)).toBe(true) + }) + + it('keeps the panel locked on step 2’s card, whose latch step 2’s press has lifted', () => { + expect(chainReviewClosed('review', raceClosed(false, true))).toBe(true) + expect(chainReviewClosed('idle', raceClosed(false, true))).toBe(true) + expect(chainReviewClosed('failed', raceClosed(false, true))).toBe(true) + }) + + it('builds no second swap when Review is pressed on the race after step 1 settled', () => { + const prepare = vi.fn() + const refuse = vi.fn() + for (const phase of ['review', 'idle', 'failed'] as const) { + expect( + reviewChainRoute({ + route: { + kind: 'chain', + venueId: 'jupiter', + request: { kind: 'swap', amount: '0.5', tokenIn: 'SOL', tokenOut: 'USDC' }, + }, + asked: { kind: 'swap', amount: '0.5', tokenIn: 'SOL', tokenOut: 'USDC' }, + text: 'swap 0.5 SOL to USDC then send to x', + phase, + signed: raceClosed(false, true), + prepare, + refuse, + }) + ).toBe('ignored') + } + expect(prepare).not.toHaveBeenCalled() + expect(refuse).not.toHaveBeenCalled() + }) +}) + +describe('stepErrorMessage', () => { + const STEP = 'This step did not start. The swap stands, and what arrived stays in your wallet.' + + it('leaves a message alone until the swap has settled', () => { + const said = 'Too much. Nothing was signed or sent.' + expect(stepErrorMessage(said, false)).toBe(said) + expect(stepErrorMessage(undefined, true)).toBeUndefined() + }) + + it('replaces the claim once the swap has been signed', () => { + expect(stepErrorMessage('Too much. Nothing was signed or sent.', true)).toBe( + `Too much. ${STEP}` + ) + expect(stepErrorMessage('A venue is busy, so nothing was signed or sent.', true)).toBe( + `A venue is busy. ${STEP}` + ) + expect( + stepErrorMessage('Not answering. Nothing was signed or sent, so try again in a moment.', true) + ).toBe(`Not answering. ${STEP}`) + }) + + it('reads correctly whatever "so try again" follows the claim', () => { + expect( + stepErrorMessage( + 'Kamino is busy right now. Nothing was signed or sent, so try again in a few seconds.', + true + ) + ).toBe(`Kamino is busy right now. ${STEP}`) + expect(stepErrorMessage('Busy, so nothing was signed or sent, so try again soon.', true)).toBe( + `Busy. ${STEP}` + ) + expect(stepErrorMessage('Not ready. Nothing was signed or sent, so try again', true)).toBe( + `Not ready. ${STEP}` + ) + }) + + it('leaves a message that makes no such claim', () => { + expect(stepErrorMessage('Could not reach the network.', true)).toBe( + 'Could not reach the network.' + ) + }) +}) + describe('reviewChainRoute after a signature', () => { const args = (over: object) => ({ route: route(), diff --git a/apps/dapp/lib/__tests__/agents-unanswered.test.ts b/apps/dapp/lib/__tests__/agents-unanswered.test.ts new file mode 100644 index 00000000..36c29113 --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-unanswered.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { SYSTEM_PROMPT, systemPromptFor } from '../agents/brief' +import { unansweredLine } from '../agents/chain-race' + +describe('unansweredLine', () => { + it('says nothing when every agent answered', () => { + expect(unansweredLine([{ name: 'DeepSeek' }, { name: 'Qwen' }])).toBeUndefined() + expect(unansweredLine([])).toBeUndefined() + }) + + it('names one agent and why', () => { + expect(unansweredLine([{ name: 'DeepSeek' }, { name: 'Gemma', failed: 'rate_limited' }])).toBe( + "1 agent didn't answer: Gemma (rate limited)." + ) + }) + + it('names several, in plain words', () => { + expect( + unansweredLine([ + { name: 'Gemma', failed: 'rate_limited' }, + { name: 'Laguna', failed: 'upstream_error' }, + { name: 'North', failed: 'timeout' }, + { name: 'Claude', failed: 'invalid_schema' }, + ]) + ).toBe( + "4 agents didn't answer: Gemma (rate limited), Laguna (provider error), North (too slow), Claude (unusable answer)." + ) + }) + + it('still names an agent whose reason it does not know', () => { + expect(unansweredLine([{ name: 'New', failed: 'something_else' }])).toBe( + "1 agent didn't answer: New (no answer)." + ) + }) +}) + +describe('what an agent is told about a next step', () => { + const brief = systemPromptFor('solana') + + it('says a later step runs on its own, so the agent does not call it unavailable', () => { + expect(brief).toMatch(/runs that as its own next step/) + expect(brief).toMatch(/do not say it is unavailable/i) + }) + + it('no longer claims there is no second step', () => { + expect(brief).not.toMatch(/There is no second step/) + }) + + it('leaves Stellar as it was', () => { + expect(systemPromptFor('stellar')).toBe(SYSTEM_PROMPT) + expect(SYSTEM_PROMPT).not.toMatch(/runs that as its own next step/) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index 201e7582..eb2efba1 100644 --- a/apps/dapp/lib/__tests__/chain-action.test.ts +++ b/apps/dapp/lib/__tests__/chain-action.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { looksLikeChainAction, parseChainAction } from '../chains/parse-action' +import { chainTokenWord, looksLikeChainAction, parseChainAction } from '../chains/parse-action' import { prepareChainAction } from '../chains/prepare' import { chainModule } from '../chains/registry' import { @@ -39,7 +39,6 @@ describe('parseChainAction', () => { it.each([ ['5', '5'], ['0.5', '0.5'], - ['05', '05'], ['1,000', '1000'], ['1,000.5', '1000.5'], ['$20', '20'], @@ -60,6 +59,42 @@ describe('parseChainAction', () => { }) }) + it.each([ + '1,5', + '0,5', + '1,50', + '10,00', + '1,0000', + ',5', + '1,,000', + '1000,000', + '0,500', + '0,050', + '0,005', + '00,500', + '01,000', + ])('reads no amount from %s, on any kind', (amount) => { + expect(parseChainAction(`send ${amount} USDC to bob.near`)).toBeNull() + expect(parseChainAction(`swap ${amount} USDC to SOL`)).toBeNull() + expect(parseChainAction(`supply ${amount} USDC`)).toBeNull() + expect(parseChainAction(`stake ${amount} SUI`)).toBeNull() + }) + + it.each([ + ['1,000', '1000'], + ['1,000.5', '1000.5'], + ['1,500', '1500'], + ['12,345,678', '12345678'], + ['.5', '0.5'], + ['05', '5'], + ['007.5', '7.5'], + ['0.5', '0.5'], + ['0', '0'], + ])('reads %s as %s', (typed, amount) => { + expect(parseChainAction(`send ${typed} USDC to bob.near`)?.amount).toBe(amount) + expect(parseChainAction(`stake ${typed} SUI`)?.amount).toBe(amount) + }) + it('does not guess at anything looser', () => { expect(parseChainAction('what is my balance?')).toBeNull() expect(parseChainAction('swap all my USDC')).toBeNull() @@ -170,3 +205,48 @@ describe('prepareChainAction', () => { ) }) }) + +describe('chainTokenWord', () => { + it.each([ + ['solana', 'SOL'], + ['Solana', 'SOL'], + ['SOLANA', 'SOL'], + ['avalanche', 'AVAX'], + ['AVALANCHE', 'AVAX'], + ['monad', 'MON'], + ['Monad', 'MON'], + ])('reads %s as %s', (word, token) => { + expect(chainTokenWord(word)).toBe(token) + }) + + it.each(['solanax', 'xsolana', 'solana ', 'sol', 'SOL', 'sui', 'near', 'usdc', 'USDC', ''])( + 'leaves %j as it is', + (word) => { + expect(chainTokenWord(word)).toBe(word) + } + ) + + it.each(['constructor', 'toString', '__proto__'])('finds nothing inherited in %s', (word) => { + expect(chainTokenWord(word)).toBe(word) + }) +}) + +describe('parseChainAction on a chain name', () => { + it('reads it as the token on every kind, in or out', () => { + expect(parseChainAction('swap 1 avalanche to usdc')).toMatchObject({ + tokenIn: 'AVAX', + tokenOut: 'usdc', + }) + expect(parseChainAction('swap 1 monad to usdc')).toMatchObject({ tokenIn: 'MON' }) + expect(parseChainAction('swap 1 usdc to Solana')).toMatchObject({ tokenOut: 'SOL' }) + expect(parseChainAction('supply 1 solana')).toMatchObject({ kind: 'supply', tokenIn: 'SOL' }) + expect(parseChainAction('stake 1 solana')).toMatchObject({ kind: 'stake', tokenIn: 'SOL' }) + }) + + it('leaves a venue that names a chain as it is', () => { + expect(parseChainAction('swap 1 sol to usdc on solana')).toMatchObject({ + tokenIn: 'sol', + venue: 'solana', + }) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts new file mode 100644 index 00000000..7cd32bbd --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-balances.test.ts @@ -0,0 +1,580 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + balanceReplyDecision, + balanceReplyIsCurrent, + chainBalanceReply, + WALLET_CHANGED_NOTICE, +} from '../chains/balance-reply' +import { readEvmBalances } from '../chains/evm/balances' +import type { EvmClient } from '../chains/evm/client' +import { AVALANCHE_TOKENS, MONAD_TOKENS } from '../chains/evm/tokens' +import { readNearBalances } from '../chains/near/balances' +import { readSolanaBalances } from '../chains/solana/balances' +import { readSuiBalances } from '../chains/sui/balances' +import { ChainActionRefused, type TokenBalance, type WalletBalances } from '../chains/types' + +const SOL_OWNER = 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr' +const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +const ODD_MINT = 'AbCdEfGhJkLmNpQrStUvWxYzAbCdEfGhJkLmNpQrStUv' +const SUI_OWNER = `0x${'ab'.repeat(32)}` +const SUI_USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' +const WAL = '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL' +const EVM_OWNER = `0x${'12'.repeat(20)}` + +type Handler = (url: string, body: unknown) => unknown +let calls: { url: string; body: unknown }[] = [] + +function stubFetch(handler: Handler): void { + calls = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + if (init?.signal?.aborted === true) throw new Error('aborted') + calls.push({ url: String(url), body }) + const out = handler(String(url), body) + if (out instanceof Response) return out + return new Response(JSON.stringify(out), { status: 200 }) + }) + ) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +function tokenAccount(mint: string, amount: string, decimals: number) { + return { + pubkey: 'x', + account: { + data: { parsed: { info: { mint, tokenAmount: { amount, decimals } } }, program: 'spl-token' }, + }, + } +} + +describe('Solana balances', () => { + const method = (body: unknown): string => (body as { method: string }).method + const params = (body: unknown): unknown[] => (body as { params: unknown[] }).params + + it('reads SOL and both token programs at confirmed, summing a mint held twice', async () => { + vi.stubEnv('SOLANA_RPC_URL', '') + stubFetch((_url, body) => { + if (method(body) === 'getBalance') return { result: { value: 14_770_000 } } + const program = (params(body)[1] as { programId: string }).programId + return { + result: { + value: + program === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' + ? [tokenAccount(USDC_MINT, '1500000', 6), tokenAccount(USDC_MINT, '500000', 6)] + : [tokenAccount(ODD_MINT, '7', 0)], + }, + } + }) + const result = await readSolanaBalances(SOL_OWNER) + expect(result.complete).toBe(true) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'SOL', amount: '0.01477', native: true, listed: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'USDC', amount: '2', decimals: 6, listed: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ id: ODD_MINT, amount: '7', listed: false, native: false }) + ) + expect(calls.every((c) => c.url === 'https://api.mainnet-beta.solana.com')).toBe(true) + for (const c of calls) { + const last = params(c.body).at(-1) as { commitment: string } + expect(last.commitment).toBe('confirmed') + } + expect(calls).toHaveLength(3) + }) + + it('uses SOLANA_RPC_URL when set', async () => { + vi.stubEnv('SOLANA_RPC_URL', 'https://rpc.example.test') + stubFetch((_url, body) => ({ + result: { value: method(body) === 'getBalance' ? 1 : [] }, + })) + await readSolanaBalances(SOL_OWNER) + expect(calls.every((c) => c.url === 'https://rpc.example.test')).toBe(true) + }) + + const listingRefused = (byMint: Record) => (_url: string, body: unknown) => { + if (method(body) === 'getBalance') return { result: { value: 5_000_000_000 } } + const filter = params(body)[1] as { programId?: string; mint?: string } + if (filter.programId !== undefined) return new Response('forbidden', { status: 403 }) + return { result: { value: byMint[filter.mint ?? ''] ?? [] } } + } + + it('falls back to each table token by mint, finding a non-associated account', async () => { + stubFetch(listingRefused({ [USDC_MINT]: [tokenAccount(USDC_MINT, '250000', 6)] })) + const result = await readSolanaBalances(SOL_OWNER) + expect(result.complete).toBe(false) + expect(result.tokens.find((t) => t.native)?.amount).toBe('5') + expect(result.tokens.find((t) => t.symbol === 'USDC')?.amount).toBe('0.25') + expect(result.tokens.find((t) => t.symbol === 'USDT')?.amount).toBe('0') + expect(result.unread ?? []).toEqual([]) + }) + + it('leaves out a table token it cannot read instead of reporting zero', async () => { + stubFetch((_url, body) => { + if (method(body) === 'getBalance') return { result: { value: 5_000_000_000 } } + const filter = params(body)[1] as { mint?: string } + if (filter.mint === USDC_MINT) return new Response('forbidden', { status: 403 }) + if (filter.mint === undefined) return new Response('forbidden', { status: 403 }) + return { result: { value: [] } } + }) + const result = await readSolanaBalances(SOL_OWNER) + expect(result.complete).toBe(false) + expect(result.unread).toEqual(['USDC']) + expect(result.tokens.find((t) => t.symbol === 'USDC')).toBeUndefined() + expect(result.tokens.find((t) => t.symbol === 'USDT')?.amount).toBe('0') + }) + + it('discards the half list when one token program refuses', async () => { + stubFetch((_url, body) => { + if (method(body) === 'getBalance') return { result: { value: 5_000_000_000 } } + const filter = params(body)[1] as { programId?: string; mint?: string } + if (filter.programId === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') { + return { result: { value: [tokenAccount(ODD_MINT, '7', 0)] } } + } + if (filter.programId !== undefined) return new Response('forbidden', { status: 403 }) + return { result: { value: [] } } + }) + const result = await readSolanaBalances(SOL_OWNER) + expect(result.complete).toBe(false) + expect(result.tokens.find((t) => t.id === ODD_MINT)).toBeUndefined() + }) + + it('refuses an address that does not decode to 32 bytes', async () => { + stubFetch(() => ({})) + await expect(readSolanaBalances('a'.repeat(32))).rejects.toBeInstanceOf(ChainActionRefused) + }) + + it('does not answer once its deadline has passed', async () => { + stubFetch(() => ({ result: { value: 1 } })) + await expect(readSolanaBalances(SOL_OWNER, AbortSignal.abort())).rejects.toThrow() + }) + + it('throws rather than answer when the SOL balance cannot be read', async () => { + stubFetch(() => new Response('nope', { status: 500 })) + await expect(readSolanaBalances(SOL_OWNER)).rejects.toThrow() + }) + + it('refuses a malformed account', async () => { + stubFetch(() => ({})) + await expect(readSolanaBalances('not an address')).rejects.toBeInstanceOf(ChainActionRefused) + }) +}) + +describe('Sui balances', () => { + const page = (nodes: unknown[], next?: string) => ({ + data: { + address: { + balances: { + pageInfo: { hasNextPage: next !== undefined, endCursor: next ?? null }, + nodes, + }, + }, + }, + }) + const node = (repr: string, totalBalance: string) => ({ coinType: { repr }, totalBalance }) + + it('reads address.balances, naming an unlisted coin by its last segment', async () => { + stubFetch((_url, body) => { + const q = (body as { query: string }).query + if (q.includes('coinMetadata')) return { data: { a0: { decimals: 9, symbol: 'WAL' } } } + return page([ + node(`0x${'0'.repeat(63)}2::sui::SUI`, '1031937015'), + node(SUI_USDC, '304803'), + node(WAL, '1500000'), + ]) + }) + const result = await readSuiBalances(SUI_OWNER) + expect(result.complete).toBe(true) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'SUI', amount: '1.031937015', native: true, listed: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'USDC', amount: '0.304803', listed: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'WAL', id: WAL, amount: '0.0015', listed: false }) + ) + }) + + it('leaves out an unlisted coin whose decimals are unknown, and says the list is incomplete', async () => { + stubFetch((_url, body) => { + const q = (body as { query: string }).query + if (q.includes('coinMetadata')) return { data: { a0: null } } + return page([node(WAL, '1500000')]) + }) + const result = await readSuiBalances(SUI_OWNER) + expect(result.tokens.find((t) => t.id === WAL)).toBeUndefined() + expect(result.complete).toBe(false) + }) + + it('follows pagination', async () => { + let n = 0 + stubFetch((_url, body) => { + const q = (body as { query: string }).query + if (q.includes('coinMetadata')) return { data: {} } + n += 1 + return n === 1 + ? page([node('0x2::sui::SUI', '1')], 'cursor1') + : page([node(SUI_USDC, '2000000')]) + }) + const result = await readSuiBalances(SUI_OWNER) + expect(result.tokens.map((t) => t.symbol).sort()).toEqual(['SUI', 'USDC']) + expect((calls[1]?.body as { variables: { c?: string } }).variables.c).toBe('cursor1') + }) + + it('says the list is incomplete when the page bound is hit', async () => { + stubFetch((_url, body) => { + const q = (body as { query: string }).query + if (q.includes('coinMetadata')) return { data: {} } + return page([node(SUI_USDC, '2000000')], 'more') + }) + const result = await readSuiBalances(SUI_OWNER) + expect(result.complete).toBe(false) + const reads = calls.filter((c) => !(c.body as { query: string }).query.includes('coinMetadata')) + expect(reads).toHaveLength(5) + }) + + it('does not call the list incomplete over a zero coin with unknown decimals', async () => { + stubFetch((_url, body) => { + const q = (body as { query: string }).query + if (q.includes('coinMetadata')) return { data: { a0: null } } + return page([node(WAL, '0')]) + }) + const result = await readSuiBalances(SUI_OWNER) + expect(result.complete).toBe(true) + }) + + it('throws when GraphQL fails', async () => { + stubFetch(() => new Response('down', { status: 502 })) + await expect(readSuiBalances(SUI_OWNER)).rejects.toThrow() + }) + + it('refuses a malformed account', async () => { + await expect(readSuiBalances('0x12')).rejects.toBeInstanceOf(ChainActionRefused) + }) +}) + +describe('NEAR balances', () => { + const USDC_NEAR = '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1' + const viewAccount = { result: { amount: '2500000000000000000000000', storage_usage: 1000 } } + const callResult = (value: unknown) => ({ + result: { result: Array.from(Buffer.from(JSON.stringify(value))) }, + }) + interface Rpc { + params: { request_type: string; account_id: string; method_name: string; finality: string } + } + const list = (tokens: unknown[]) => ({ account_id: 'alice.near', tokens }) + + function chain( + fast: () => unknown, + held: Record = {}, + onCall?: (b: Rpc) => unknown + ) { + return (url: string, body: unknown) => { + if (url.includes('fastnear.com/v1')) return fast() + const b = body as Rpc + if (b.params.request_type === 'view_account') return viewAccount + const custom = onCall?.(b) + if (custom !== undefined) return custom + if (b.params.method_name === 'ft_balance_of') { + return callResult(held[b.params.account_id] ?? '0') + } + return callResult({ symbol: 'EXA', decimals: 3 }) + } + } + + it('reads table tokens on chain, and unlisted ones from FastNEAR with metadata', async () => { + stubFetch( + chain( + () => + list([ + { contract_id: 'token.example.near', last_update_block_height: 1, balance: '42000' }, + { contract_id: 'empty.near', last_update_block_height: 1, balance: '0' }, + ]), + { [USDC_NEAR]: '3000000' } + ) + ) + const result = await readNearBalances('alice.near') + expect(result.complete).toBe(true) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'NEAR', amount: '2.5', native: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'USDC', amount: '3', listed: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ + symbol: 'EXA', + id: 'token.example.near', + amount: '42', + listed: false, + }) + ) + expect(result.tokens.find((t) => t.id === 'empty.near')).toBeUndefined() + }) + + it('shows a token FastNEAR has not indexed yet, read at finality final', async () => { + const finalities: string[] = [] + stubFetch( + chain( + () => list([]), + { [USDC_NEAR]: '5000000' }, + (b) => { + finalities.push(b.params.finality) + return undefined + } + ) + ) + const result = await readNearBalances('alice.near') + expect(result.complete).toBe(true) + expect(result.tokens.find((t) => t.symbol === 'USDC')?.amount).toBe('5') + expect(finalities.length).toBeGreaterThan(0) + expect(finalities.every((f) => f === 'final')).toBe(true) + }) + + it('throws when a table token cannot be read, rather than reading zero', async () => { + stubFetch( + chain( + () => list([]), + {}, + (b) => (b.params.account_id === USDC_NEAR ? { error: { name: 'UNKNOWN' } } : undefined) + ) + ) + await expect(readNearBalances('alice.near')).rejects.toThrow() + }) + + it('gives the table tokens with complete false when FastNEAR fails', async () => { + stubFetch(chain(() => new Response('x', { status: 500 }), { [USDC_NEAR]: '1000000' })) + const result = await readNearBalances('alice.near') + expect(result.complete).toBe(false) + expect(result.tokens.find((t) => t.symbol === 'USDC')?.amount).toBe('1') + expect(result.tokens.find((t) => t.native)?.amount).toBe('2.5') + }) + + it('caps metadata reads at 20 and says the list is incomplete', async () => { + const many = Array.from({ length: 25 }, (_, i) => ({ + contract_id: `t${i}.near`, + last_update_block_height: 1, + balance: '1000', + })) + stubFetch(chain(() => list(many))) + const result = await readNearBalances('alice.near') + expect(result.complete).toBe(false) + expect(result.tokens.filter((t) => !t.listed && !t.native)).toHaveLength(20) + }) + + it('refuses a malformed account', async () => { + await expect(readNearBalances('Not Valid!')).rejects.toBeInstanceOf(ChainActionRefused) + }) +}) + +describe('EVM balances', () => { + function client(): EvmClient & { read: string[] } { + const read: string[] = [] + return { + read, + tokenBalance: async (token) => { + read.push(token) + return BigInt(2500000) + }, + nativeBalance: async () => BigInt('1500000000000000000'), + waitForReceipt: async () => { + throw new Error('unused') + }, + transaction: async () => { + throw new Error('unused') + }, + } + } + + it('reads native and each table token and is never complete', async () => { + const c = client() + const result = await readEvmBalances('avalanche', EVM_OWNER, c) + expect(result.complete).toBe(false) + expect(result.knownOnly).toBe(true) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'AVAX', amount: '1.5', native: true }) + ) + expect(result.tokens).toContainEqual( + expect.objectContaining({ symbol: 'USDC', amount: '2.5', listed: true }) + ) + expect(c.read).toHaveLength(AVALANCHE_TOKENS.length - 1) + }) + + it('never reads Monad table tokens on Avalanche', async () => { + const c = client() + await readEvmBalances('avalanche', EVM_OWNER, c) + for (const t of MONAD_TOKENS) expect(c.read).not.toContain(t.id) + const m = client() + await readEvmBalances('monad', EVM_OWNER, m) + for (const t of AVALANCHE_TOKENS) expect(m.read).not.toContain(t.id) + }) + + it('throws when a read fails', async () => { + const c = client() + c.tokenBalance = async () => { + throw new Error('rpc down') + } + await expect(readEvmBalances('monad', EVM_OWNER, c)).rejects.toThrow() + }) + + it('refuses a malformed account', async () => { + await expect(readEvmBalances('monad', '0x12', client())).rejects.toBeInstanceOf( + ChainActionRefused + ) + }) +}) + +describe('balanceReplyIsCurrent', () => { + const asked = { chain: 'solana', account: 'A', turn: 3 } + + it('keeps a reply when nothing changed', () => { + expect(balanceReplyIsCurrent(asked, { ...asked })).toBe(true) + }) + + it('drops it when another wallet connected', () => { + expect(balanceReplyIsCurrent(asked, { ...asked, account: 'B' })).toBe(false) + }) + + it('drops it when the chain changed', () => { + expect(balanceReplyIsCurrent(asked, { ...asked, chain: 'sui' })).toBe(false) + }) + + it('drops it when a newer message was sent', () => { + expect(balanceReplyIsCurrent(asked, { ...asked, turn: 4 })).toBe(false) + }) + + it('drops it when the wallet disconnected', () => { + expect(balanceReplyIsCurrent(asked, { ...asked, account: undefined })).toBe(false) + }) +}) + +describe('balanceReplyDecision', () => { + it('shows the reply for a person who was never connected and still is not', () => { + const none = { chain: 'stellar', account: undefined, turn: 2 } + expect(balanceReplyDecision(none, { ...none })).toEqual({ kind: 'show' }) + expect(balanceReplyIsCurrent(none, { ...none })).toBe(true) + }) + + it('gives the notice when connected then disconnected, or the reverse', () => { + const connected = { chain: 'solana', account: 'A', turn: 2 } + expect(balanceReplyDecision(connected, { ...connected, account: undefined }).kind).toBe( + 'notice' + ) + expect(balanceReplyDecision({ ...connected, account: undefined }, connected).kind).toBe( + 'notice' + ) + }) + + const asked = { chain: 'solana', account: 'A', turn: 3 } + + it('shows the reply when nothing changed', () => { + expect(balanceReplyDecision(asked, { ...asked })).toEqual({ kind: 'show' }) + }) + + it('tells the person to ask again when the wallet changed and they sent nothing since', () => { + expect(balanceReplyDecision(asked, { ...asked, account: 'B' })).toEqual({ + kind: 'notice', + text: WALLET_CHANGED_NOTICE, + }) + expect(balanceReplyDecision(asked, { ...asked, chain: 'sui' }).kind).toBe('notice') + expect(WALLET_CHANGED_NOTICE).toBe('Your wallet changed, so ask again for its balance.') + }) + + it('says nothing when a newer message was sent or the chat was cleared', () => { + expect(balanceReplyDecision(asked, { ...asked, turn: 4 })).toEqual({ kind: 'drop' }) + expect(balanceReplyDecision(asked, { ...asked, turn: 4, account: 'B' })).toEqual({ + kind: 'drop', + }) + }) +}) + +describe('chain balances route', () => { + it('is bounded to 20 seconds', async () => { + const route = await import('../../app/api/chain/[chain]/balances/route') + expect(route.maxDuration).toBe(20) + }) +}) + +describe('chainBalanceReply', () => { + const t = (over: Partial & { symbol: string }): TokenBalance => ({ + id: over.symbol, + amount: '1', + decimals: 6, + native: false, + listed: true, + ...over, + }) + const wallet = (tokens: TokenBalance[], complete = true): WalletBalances => ({ + tokens, + complete, + }) + + it('lists native first, then listed, then unlisted with the shortened id', () => { + const reply = chainBalanceReply( + wallet([ + t({ symbol: ODD_MINT, id: ODD_MINT, amount: '7', listed: false }), + t({ symbol: 'USDC', amount: '2' }), + t({ symbol: 'SOL', amount: '0.01477', native: true }), + ]), + 'Solana' + ) + const lines = reply.split('\n') + expect(lines[0]).toBe('Your wallet holds:') + expect(lines[1]).toBe('• 0.01477 SOL') + expect(lines[2]).toBe('• 2 USDC') + expect(lines[3]).toBe('• 7 AbCd…StUv') + expect(reply).not.toContain('Only tokens') + }) + + it('drops zero balances except native', () => { + const reply = chainBalanceReply( + wallet([ + t({ symbol: 'SOL', amount: '0', native: true }), + t({ symbol: 'USDC', amount: '0' }), + t({ symbol: 'USDT', amount: '3' }), + ]), + 'Solana' + ) + expect(reply).toContain('• 0 SOL') + expect(reply).not.toContain('USDC') + expect(reply).toContain('• 3 USDT') + }) + + it('ends with "and N more" past 30 tokens', () => { + const many = Array.from({ length: 35 }, (_, i) => t({ symbol: `T${i}`, id: `T${i}` })) + const reply = chainBalanceReply(wallet(many), 'Sui') + expect(reply.split('\n').filter((l) => l.startsWith('•'))).toHaveLength(30) + expect(reply).toContain('and 5 more') + }) + + it('says only known tokens are shown when an EVM chain is incomplete', () => { + const reply = chainBalanceReply( + { ...wallet([t({ symbol: 'AVAX', native: true })], false), knownOnly: true }, + 'Avalanche' + ) + expect(reply).toContain('Only tokens Intent knows on Avalanche are shown.') + }) + + it('says some tokens could not be read, not that only known ones are shown, elsewhere', () => { + const reply = chainBalanceReply(wallet([t({ symbol: 'SUI', native: true })], false), 'Sui') + expect(reply).toContain('Some tokens could not be read.') + expect(reply).not.toContain('Only tokens') + }) + + it('never gives a number when nothing was read', () => { + const reply = chainBalanceReply(undefined, 'Solana') + expect(reply).toBe( + "I could not read your balance just now, so I won't guess. Try again in a moment." + ) + expect(reply).not.toMatch(/\d/) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-dollar-amount.test.ts b/apps/dapp/lib/__tests__/chain-dollar-amount.test.ts new file mode 100644 index 00000000..84d33fc9 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-dollar-amount.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' + +import { sizedLine, sizeInUnits, unitsForDollars } from '../chains/dollar-amount' +import { dollarsOn, parseChainAction } from '../chains/parse-action' +import { chainRouteFromReading, readChainMessage, requestSaid } from '../chains/parse-sequence' +import { cashoutSequence, dollarsSaid } from '../chains/sequence-flow' +import { parseCashout, routeCashout } from '../weave/parse-cashout' + +const reading = (over: object = {}) => ({ + action: 'swap', + tokenIn: 'SOL', + tokenOut: 'USDC', + amountUsd: 1, + amountStated: true, + amountIsUsd: true, + amountOf: 'sent' as const, + followOn: { kind: 'offramp', venue: 'weave' }, + ...over, +}) + +describe('a figure in dollars never reads as that many tokens', () => { + it.each([ + ['swap $5 SOL to USDC', 'swap'], + ['supply $5 SOL', 'supply'], + ['stake $5 SOL', 'stake'], + ['send $5 SOL to abc.near', 'send'], + ])('%s', (typed, kind) => { + expect(parseChainAction(typed)).toMatchObject({ kind, amount: '5', amountIsUsd: true }) + }) + + it('reads the same in a sequence', () => { + const read = readChainMessage('swap $5 sol to usdc then cash out', 'solana', 'Solana') + expect(read).toMatchObject({ + kind: 'sequence', + request: { swap: { amount: '5', amountIsUsd: true } }, + }) + }) + + it('takes dollars of USDC at face value', () => { + expect(parseChainAction('swap $5 USDC to SOL')).toEqual({ + kind: 'swap', + amount: '5', + tokenIn: 'USDC', + tokenOut: 'SOL', + venue: undefined, + }) + }) + + it('does not mark a count of tokens as dollars', () => { + expect(parseChainAction('swap 5 SOL to USDC')).not.toHaveProperty('amountIsUsd') + expect(dollarsOn('deposit 5 SOL on $foo', 'SOL')).toEqual({}) + }) + + it('reads a cash-out of dollars of a token as a sequence on a chain that can finish it', () => { + expect(routeCashout(parseCashout('cash out $5 sol to kes'), 'solana')).toEqual({ + kind: 'sequence', + token: 'SOL', + amount: '5', + amountIsUsd: true, + currency: 'KES', + }) + expect( + cashoutSequence({ kind: 'sequence', token: 'SOL', amount: '5', amountIsUsd: true }).swap + ).toMatchObject({ amount: '5', amountIsUsd: true }) + }) + + it('leaves Stellar and chains that cannot finish it as they were', () => { + expect(routeCashout(parseCashout('cash out $5 xlm'), 'stellar')).toEqual({ kind: 'pass' }) + expect(routeCashout(parseCashout('cash out $5 near'), 'near')).toEqual({ + kind: 'token', + token: 'NEAR', + }) + }) +}) + +describe('dollarsSaid', () => { + const said = (typed: string[], amount = '1', out?: string) => + dollarsSaid(amount, 'SOL', out, typed) + + it.each([ + 'offramp $1 worth of solana', + 'offramp $1 of sol', + 'offramp $ 1 of my sol', + 'cash out 1 dollar of sol', + '1 usd worth of solana to my bank', + 'cash out $1.00 sol', + 'sell sol worth $1', + ])('finds %s', (typed) => { + expect(said([typed])).toBe(true) + }) + + it.each([ + ['offramp 1 sol'], + ['offramp $15 of sol'], + ['offramp $0.1 of sol'], + ['offramp $1,5 of sol'], + ['offramp ₦1 of sol'], + ['offramp 1000 naira worth of sol'], + ['offramp $1 of usdt'], + ['swap $1 usdc to sol'], + ])('refuses %s', (typed) => { + expect(said([typed])).toBe(false) + }) + + it('goes by the last figure said', () => { + expect(said(['swap $5 of sol to usdc', 'actually $1'], '1', 'USDC')).toBe(true) + expect(said(['swap $5 of sol to usdc', 'actually $1'], '5', 'USDC')).toBe(false) + expect(said(['swap $1 of sol to usdc', 'no make it 2'], '1', 'USDC')).toBe(false) + }) + + it('wants the token received named after the token given up', () => { + expect(said(['swap $1 of sol to usdc'], '1', 'USDC')).toBe(true) + expect(said(['swap $1 of usdc to sol'], '1', 'USDC')).toBe(false) + expect(said(['swap $1 to sol from usdc'], '1', 'USDC')).toBe(false) + }) + + it('is what requestSaid goes by for dollars', () => { + const swap = { + kind: 'swap' as const, + amount: '1', + tokenIn: 'SOL', + tokenOut: 'USDC', + venue: undefined, + amountIsUsd: true as const, + } + expect(requestSaid(swap, ['offramp $1 worth of solana'], true)).toBe(true) + expect(requestSaid(swap, ['offramp $1 worth of solana'])).toBe(false) + expect(requestSaid(swap, ['offramp 1 solana'], true)).toBe(false) + }) +}) + +describe('chainRouteFromReading with dollars', () => { + it('runs the sentence that was refused', () => { + const route = chainRouteFromReading(reading(), 'Swap $1 of SOL to USDC, then cash out.', [ + 'offramp $1 worth of solana', + ]) + expect(route).toMatchObject({ + sequence: { + swap: { amount: '1', tokenIn: 'SOL', amountIsUsd: true }, + followOn: { kind: 'cashout' }, + }, + }) + }) + + it('refuses a dollar figure the person never typed', () => { + const route = chainRouteFromReading(reading({ amountUsd: 500 }), 'Swap $500 of SOL to USDC.', [ + 'offramp $1 worth of solana', + ]) + expect(route).toHaveProperty('refusal') + }) + + it('refuses dollars the person said in tokens', () => { + const route = chainRouteFromReading(reading(), 'Swap $1 of SOL to USDC.', ['offramp 1 sol']) + expect(route).toHaveProperty('refusal') + }) + + it('runs a plain swap in dollars', () => { + const five = chainRouteFromReading( + reading({ followOn: null, amountUsd: 5 }), + 'Swap $5 of SOL to USDC.', + ['swap $5 of sol to usdc'] + ) + expect(five).toMatchObject({ action: { amount: '5', amountIsUsd: true } }) + }) +}) + +describe('unitsForDollars', () => { + it('divides by the price and rounds down, so it never spends more than was said', () => { + expect(unitsForDollars('1', 96.66, 9)).toBe('0.01034554') + expect(unitsForDollars('5', 2, 9)).toBe('2.5') + }) + + it('keeps to what the token can hold', () => { + expect(unitsForDollars('1', 3, 6)).toBe('0.333333') + }) + + it('gives nothing for a sum too small to hold, or a bad price', () => { + expect(unitsForDollars('0.000001', 100000, 6)).toBeUndefined() + expect(unitsForDollars('1', 0, 9)).toBeUndefined() + expect(unitsForDollars('0', 5, 9)).toBeUndefined() + }) +}) + +describe('sizeInUnits', () => { + const swap = { + kind: 'swap' as const, + amount: '1', + tokenIn: 'SOL', + tokenOut: 'USDC', + venue: undefined, + amountIsUsd: true as const, + } + const answers = (status: number, body: unknown) => async () => ({ + status, + json: async () => body, + }) + + it('swaps the dollars for a count of tokens, with no mark left on it', async () => { + const out = await sizeInUnits('solana', swap, answers(200, { priceUsd: 96.66, decimals: 9 })) + expect(out).toEqual({ + request: { + kind: 'swap', + amount: '0.01034554', + tokenIn: 'SOL', + tokenOut: 'USDC', + venue: undefined, + }, + line: sizedLine('1', 'SOL', 96.66, '0.01034554'), + }) + }) + + it('takes USDC at a dollar, asking nothing', async () => { + const asked = () => { + throw new Error('should not read a price') + } + await expect(sizeInUnits('solana', { ...swap, tokenIn: 'USDC' }, asked)).resolves.toMatchObject( + { + request: { amount: '1' }, + } + ) + }) + + it.each([ + ['no price', answers(503, { error: 'price_unavailable' })], + ['an unknown token', answers(404, { error: 'unknown_token' })], + ['a nonsense price', answers(200, { priceUsd: -1, decimals: 9 })], + ['a missing decimals', answers(200, { priceUsd: 5 })], + [ + 'a failed read', + async () => { + throw new Error('down') + }, + ], + ])('refuses on %s, and never guesses', async (_, reader) => { + const out = await sizeInUnits('solana', swap, reader) + expect(out).toHaveProperty('refusal') + expect((out as { refusal: string }).refusal).toContain('Nothing was signed or sent.') + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-dollar-words.test.ts b/apps/dapp/lib/__tests__/chain-dollar-words.test.ts new file mode 100644 index 00000000..3188a8b5 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-dollar-words.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { unitsForDollars } from '../chains/dollar-amount' +import { requestSaid } from '../chains/parse-sequence' +import { cashoutSaid, dollarsSaid, sequenceWordsMatch } from '../chains/sequence-flow' + +const count = (amount: string) => ({ amount, token: 'SOL' }) +const swap = (amount: string, amountIsUsd = false) => ({ + kind: 'swap' as const, + amount, + tokenIn: 'SOL', + tokenOut: 'USDC', + venue: undefined, + ...(amountIsUsd ? { amountIsUsd: true as const } : {}), +}) + +describe('when dollars and a count are both in what was said', () => { + it('does not turn a correction of dollars into that many tokens', () => { + const said = ['swap $1 of sol to usdc, no make it 2'] + expect(sequenceWordsMatch(count('2'), said)).toBe(false) + expect(requestSaid(swap('2'), said)).toBe(false) + expect(requestSaid(swap('2'), ['swap $1 of sol to usdc', 'no make it 2'])).toBe(false) + }) + + it('still takes a correction that names the token', () => { + expect(requestSaid(swap('2'), ['swap $1 of sol to usdc', 'make it 2 sol'])).toBe(true) + }) + + it.each([ + ['swap 5 sol to usdc if the price is above $100', '5', '100'], + ['cash out 2 sol, about $300.', '2', '300'], + ])('lets the model choose neither in: %s', (typed, tokens, dollars) => { + expect(sequenceWordsMatch(count(tokens), [typed])).toBe(false) + expect(dollarsSaid(dollars, 'SOL', undefined, [typed])).toBe(false) + }) + + it('refuses both readings of a count then a correction in dollars', () => { + const said = ['swap 2 sol to usdc', 'actually $5'] + expect(requestSaid(swap('2'), said)).toBe(false) + expect(requestSaid(swap('5', true), said)).toBe(false) + }) + + it('reads a figure held in dollars apart from a count spent', () => { + const said = ['I have $100 of sol, swap 2 sol to usdc'] + expect(requestSaid(swap('2'), said)).toBe(true) + expect(requestSaid(swap('100', true), said)).toBe(false) + }) + + it('applies to a cash-out the model wrote out', () => { + const route = { kind: 'sequence' as const, token: 'SOL', amount: '2' } + expect(cashoutSaid(route, ['cash out 2 sol, about $300.'])).toBe(false) + expect( + cashoutSaid({ ...route, amount: '300', amountIsUsd: true }, ['cash out 2 sol, about $300.']) + ).toBe(false) + expect(cashoutSaid({ ...route, amount: '5', amountIsUsd: true }, ['cash out $5 of sol'])).toBe( + true + ) + }) +}) + +describe('a bare dollar figure', () => { + it('counts as a correction of dollars, in its own turn or after a word that says so', () => { + expect(dollarsSaid('1', 'SOL', undefined, ['swap $5 of sol', '$1'])).toBe(true) + expect(dollarsSaid('1', 'SOL', undefined, ['swap $5 of sol', 'actually $1'])).toBe(true) + expect(dollarsSaid('1', 'SOL', undefined, ['swap $5 of sol, no make it $1'])).toBe(true) + }) + + it('does not count when it is a condition on the swap', () => { + const said = ['swap $5 of sol to usdc if the price is above $100'] + expect(dollarsSaid('100', 'SOL', 'USDC', said)).toBe(false) + expect(dollarsSaid('5', 'SOL', 'USDC', said)).toBe(false) + }) + + it('still reads over a question answered in between', () => { + const said = ['offramp $1 worth of solana', 'naira'] + expect(dollarsSaid('1', 'SOL', undefined, said)).toBe(true) + }) +}) + +describe('other dollars', () => { + it.each(['cash out R$100 of sol', 'cash out A$100 of sol', 'cash out CA$100 of sol'])( + 'does not read %s as US dollars', + (typed) => { + expect(dollarsSaid('100', 'SOL', undefined, [typed])).toBe(false) + } + ) + + it('reads US$ as dollars', () => { + expect(dollarsSaid('100', 'SOL', undefined, ['cash out US$100 of sol'])).toBe(true) + }) +}) + +describe('unitsForDollars with a sum too big to write out', () => { + it('gives nothing, rather than a figure in exponent form', () => { + expect(unitsForDollars('1'.repeat(31), 96.66, 9)).toBeUndefined() + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-price-route.test.ts b/apps/dapp/lib/__tests__/chain-price-route.test.ts new file mode 100644 index 00000000..23f2ef90 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-price-route.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const priceUsd = vi.fn() + +vi.mock('../chains/registry', () => ({ + chainModule: (chain: string) => + chain === 'solana' + ? { + chain, + tokens: [{ symbol: 'SOL', id: 'so11', decimals: 9 }], + priceUsd, + } + : undefined, +})) + +import { GET } from '../../app/api/chain/[chain]/price/route' + +const ask = (chain: string, token: string) => + GET(new Request(`http://localhost/api/chain/${chain}/price?token=${token}`), { + params: Promise.resolve({ chain }), + }) + +describe('the chain price route', () => { + beforeEach(() => { + priceUsd.mockReset() + }) + + it('answers a listed token, in any case', async () => { + priceUsd.mockResolvedValue(96.66) + const res = await ask('solana', 'sol') + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ priceUsd: 96.66, decimals: 9 }) + }) + + it('reads no price for a token the chain does not list', async () => { + const res = await ask('solana', 'JUNK') + expect(res.status).toBe(404) + expect(priceUsd).not.toHaveBeenCalled() + }) + + it('refuses a chain it has no module for', async () => { + expect((await ask('dogecoin', 'SOL')).status).toBe(404) + }) + + it('says so when there is no price, rather than giving one', async () => { + priceUsd.mockResolvedValue(undefined) + expect((await ask('solana', 'SOL')).status).toBe(503) + priceUsd.mockRejectedValue(new Error('down')) + expect((await ask('solana', 'SOL')).status).toBe(503) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-send.test.ts b/apps/dapp/lib/__tests__/chain-send.test.ts new file mode 100644 index 00000000..b40e6a91 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-send.test.ts @@ -0,0 +1,1601 @@ +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 { + encodeAbiParameters, + encodeEventTopics, + erc20Abi, + getAddress, + zeroAddress, + type Hex, +} from 'viem' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { POST as FOLLOW_ON } from '../../app/api/chain/[chain]/follow-on/route' +import { POST as PREPARE } from '../../app/api/chain/[chain]/prepare/route' +import { quoteChainRoutes } from '../agents/chain-routes' +import { buildEvmVenues, avalancheModule, monadModule } from '../chains/evm/module' +import { AVALANCHE_TOKENS, EVM_NATIVE_ID, MONAD_TOKENS } from '../chains/evm/tokens' +import { buildNearVenues, nearModule } from '../chains/near/module' +import { isRegistered, waitForTransaction } from '../chains/near/rpc' +import { assertOneClickSafe, TRANSFER_GAS } from '../chains/near/safety' +import { USDC_NEAR, USDT_NEAR, WRAP_NEAR, nearTokens } from '../chains/near/tokens' +import { parseChainAction, racesFirst } from '../chains/parse-action' +import { assertFollowOnCanRun, prepareChainAction } from '../chains/prepare' +import { isValidRecipient, sendRefusal } from '../chains/recipient' +import { chainModule } from '../chains/registry' +import { solanaDeposit } from '../weave/deposit/solana' +import { SEND_NOTE, SEND_VENUE_ID, createSendVenue } from '../chains/send' +import { followOnRefusal } from '../chains/sequence' +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from '../chains/solana/assert-safe' +import { buildSolanaVenues, solanaModule } from '../chains/solana/module' +import { SOLANA_TOKENS } from '../chains/solana/tokens' +import { buildSuiVenues, suiModule } from '../chains/sui/module' +import { SUI_TOKENS } from '../chains/sui/tokens' +import { afterSubmitting } from '../chains/signing-flow' +import { + ChainActionRefused, + tokenBySymbol, + type ChainActionRequest, + type ChainModule, + type ChainToken, + type ChainWithActions, + type PreparedAction, + type SigningPayload, + type SubmitResult, +} from '../chains/types' +import { venues } from '../venues' + +vi.hoisted(() => { + process.env['AUTH_SECRET'] = 'x'.repeat(48) +}) + +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 }), +})) + +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 }>, +})) + +vi.mock('../chains/evm/client', async (original) => ({ + ...(await original()), + evmClient: () => ({ + tokenBalance: (token: string, account: string) => rpc.tokenBalance(token, account), + nativeBalance: (account: string) => rpc.nativeBalance(account), + waitForReceipt: (hash: string) => rpc.waitForReceipt(hash), + transaction: (hash: string) => rpc.transaction(hash), + }), +})) + +vi.mock('../chains/near/rpc', async (original) => ({ + ...(await original()), + isRegistered: vi.fn(), + waitForTransaction: vi.fn(), +})) + +vi.mock('../chains/registry', () => ({ chainModule: vi.fn() })) +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) + +const SOL_ME = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' +const SOL_TO = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' +const SOL_THIEF = 'DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy' +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' + +const SUI_ME = normalizeSuiAddress('0xa1') +const SUI_TO = normalizeSuiAddress('0xb1') +const SUI_THIEF = normalizeSuiAddress('0xdead') +const COIN_A = normalizeSuiAddress('0xc1') +const GAS_COIN = normalizeSuiAddress('0xf1') +const DIGEST = 'GKot5hBsd81kMupNCXHaqbhv3huEbxAFMLnpcX2hniwn' + +const EVM_ME = getAddress(`0x${'a1'.repeat(20)}`) +const EVM_TO = getAddress(`0x${'b2'.repeat(20)}`) +const EVM_THIEF = getAddress(`0x${'c3'.repeat(20)}`) +const EVM_HASH = `0x${'4'.repeat(64)}` + +const NEAR_ME = 'alice.near' +const NEAR_TO = 'bob.near' +const NEAR_THIEF = 'mallory.near' +const NEAR_HASH = '4'.repeat(44) + +const WORDS_FOR_A_DEPOSIT = /\border\b|\bswap\b|\bdeposit\b|NEAR Intents|1Click/i + +const SOL_USDC = tokenBySymbol(SOLANA_TOKENS, 'USDC') as ChainToken +const SUI_USDC = tokenBySymbol(SUI_TOKENS, 'USDC') as ChainToken +const AVAX_USDC = tokenBySymbol(AVALANCHE_TOKENS, 'USDC') as ChainToken + +function request(over: Partial): ChainActionRequest { + return { kind: 'send', amount: '5', tokenIn: 'USDC', ...over } +} + +const TOKEN_TABLES: Record = { + solana: SOLANA_TOKENS, + sui: SUI_TOKENS, + near: nearTokens, + avalanche: AVALANCHE_TOKENS, + monad: MONAD_TOKENS, +} + +function moduleFor(chain: ChainWithActions, over: Partial = {}): ChainModule { + return { + chain, + tokens: TOKEN_TABLES[chain], + venues: [createSendVenue(chain)], + defaultVenue: { send: SEND_VENUE_ID }, + priceUsd: async () => 1, + submit: async () => ({ ok: false, error: 'unused' }), + balances: async () => ({ tokens: [], complete: false }), + ...over, + } +} + +interface Sim { + rpc: string[] + accountOwner: string | undefined + accountExecutable: boolean + send: () => unknown +} + +let sim: Sim + +function installFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: string | URL, init?: RequestInit) => { + const { method } = JSON.parse(init?.body as string) as { method: string } + sim.rpc.push(method) + const replies: Record = { + getAccountInfo: { + result: { + context: { slot: 1 }, + value: + sim.accountOwner === undefined + ? null + : { owner: sim.accountOwner, executable: sim.accountExecutable }, + }, + }, + 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 new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, ...(replies[method] as object) })) + }) + ) +} + +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(() => { + sim = { + rpc: [], + accountOwner: undefined, + accountExecutable: false, + send: () => ({ result: 'sent' }), + } + installFetch() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) + 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 } } }, + }) + rpc.tokenBalance = async () => BigInt(90_000_000) + rpc.nativeBalance = async () => BigInt('90000000000000000000') + rpc.waitForReceipt = async () => { + throw new Error('no receipt') + } + rpc.transaction = async () => ({ value: BigInt(0), input: '0x' }) + vi.mocked(isRegistered).mockReset() + vi.mocked(isRegistered).mockResolvedValue(true) + vi.mocked(waitForTransaction).mockReset() +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +function refusal(work: () => unknown): string { + try { + work() + } catch (e) { + expect(e).toBeInstanceOf(ChainActionRefused) + return (e as Error).message + } + throw new Error('it was not refused') +} + +async function refusalOf(work: Promise): Promise { + const caught = await work.then( + () => undefined, + (e: unknown) => e + ) + expect(caught).toBeInstanceOf(ChainActionRefused) + return (caught as Error).message +} + +/** Bytes to a chosen recipient, carrying an extra transfer when asked, as a tampered browser's. */ +function solanaBytes(fields: { to?: string; extra?: boolean } = {}): string { + const mint = SOL_USDC.id + const destination = associatedTokenAccount(fields.to ?? SOL_TO, mint) + const transfer = (amount: bigint, target: string) => ({ + programAddress: TOKEN_PROGRAM as Address, + accounts: [ + { address: associatedTokenAccount(SOL_ME, mint) as Address, role: AccountRole.WRITABLE }, + { address: mint as Address, role: AccountRole.READONLY }, + { address: target as Address, role: AccountRole.WRITABLE }, + { address: SOL_ME as Address, role: AccountRole.READONLY_SIGNER }, + ], + data: Uint8Array.from([12, ...getU64Encoder().encode(amount), 6]), + }) + const message = pipe( + createTransactionMessage({ version: 'legacy' }), + (m) => setTransactionMessageFeePayer(SOL_ME as Address, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: BLOCKHASH as Blockhash, lastValidBlockHeight: BigInt(9) }, + m + ), + (m) => + appendTransactionMessageInstructions( + [ + { + programAddress: ASSOCIATED_TOKEN_PROGRAM as Address, + accounts: [ + { address: SOL_ME as Address, role: AccountRole.WRITABLE_SIGNER }, + { address: destination as Address, role: AccountRole.WRITABLE }, + { address: (fields.to ?? SOL_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(BigInt(5_000_000), destination), + ...(fields.extra === true + ? [transfer(BigInt(1), associatedTokenAccount(SOL_THIEF, mint))] + : []), + ] as never, + m + ) + ) + return getBase64EncodedWireTransaction(compileTransaction(message as never)) +} + +function signedBytes(base64: string): string { + const bytes = Buffer.from(base64, 'base64') + bytes.fill(7, 1, 65) + return bytes.toString('base64') +} + +function suiBytes(fields: { to?: string; extra?: boolean } = {}): Promise { + const tx = new Transaction() + tx.setSender(SUI_ME) + tx.setGasOwner(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, [BigInt(5_000_000)]) + tx.transferObjects([split as never], fields.to ?? SUI_TO) + if (fields.extra === true) { + const [more] = tx.splitCoins(coin, [BigInt(1)]) + tx.transferObjects([more as never], SUI_THIEF) + } + return tx.build().then(toBase64) +} + +function transferLog(to: string, value: bigint, token = AVAX_USDC.id) { + return { + address: token, + topics: encodeEventTopics({ + abi: erc20Abi, + eventName: 'Transfer', + args: { from: EVM_ME, to: to as Hex }, + }), + data: encodeAbiParameters([{ type: 'uint256' }], [value]), + blockNumber: BigInt(1), + transactionHash: EVM_HASH, + transactionIndex: 0, + blockHash: `0x${'5'.repeat(64)}`, + logIndex: 0, + removed: false, + } +} + +describe('isValidRecipient', () => { + it.each([ + ['solana', SOL_TO], + ['sui', SUI_TO], + ['avalanche', EVM_TO], + ['monad', EVM_TO], + ['near', 'bob.near'], + ['near', 'a'.repeat(64)], + ['near', 'sub.bob.near'], + ] as const)('takes a real address on %s: %s', (chain, address) => { + expect(isValidRecipient(chain, address)).toBe(true) + }) + + it.each([ + ['an EVM address on Solana', 'solana', EVM_TO], + ['a Sui address on Solana', 'solana', SUI_TO], + ['a name on Solana', 'solana', 'alice'], + ['a Solana address on Sui', 'sui', SOL_TO], + ['an EVM address on Sui', 'sui', EVM_TO], + ['a Sui address without 0x', 'sui', SUI_TO.slice(2)], + ['a short Sui address', 'sui', '0xa1'], + ['a Solana address on Avalanche', 'avalanche', SOL_TO], + ['a Sui address on Monad', 'monad', SUI_TO], + ['an EVM address on NEAR', 'near', EVM_TO.toLowerCase()], + ['a Solana address on NEAR', 'near', SOL_TO], + ['a NEAR name with capitals', 'near', 'Bob.near'], + ['a NEAR name with no dot', 'near', 'bob'], + ] as const)('refuses %s', (_name, chain, address) => { + expect(isValidRecipient(chain, address)).toBe(false) + }) + + it('takes an EVM address in all lower case, and refuses a wrong checksum', () => { + const lower = EVM_TO.toLowerCase() + expect(isValidRecipient('avalanche', lower)).toBe(true) + expect(isValidRecipient('monad', lower)).toBe(true) + const wrong = `0x${'B2'.repeat(19)}b2` + expect(getAddress(wrong)).not.toBe(wrong) + expect(isValidRecipient('avalanche', wrong)).toBe(false) + expect(isValidRecipient('monad', wrong)).toBe(false) + }) + + it.each([ + ['solana', '11111111111111111111111111111111'], + ['sui', `0x${'0'.repeat(64)}`], + ['near', '0'.repeat(64)], + ['avalanche', zeroAddress], + ['monad', zeroAddress], + ] as const)('refuses the zero address on %s', (chain, address) => { + expect(isValidRecipient(chain, address)).toBe(false) + }) + + it('refuses every token contract in each chain’s table', () => { + for (const token of SOLANA_TOKENS) expect(isValidRecipient('solana', token.id)).toBe(false) + for (const token of nearTokens) { + if (token.id.includes('.') || token.id.length === 64) { + expect(isValidRecipient('near', token.id)).toBe(false) + } + } + for (const list of [AVALANCHE_TOKENS, MONAD_TOKENS]) { + for (const token of list) { + if (token.id === EVM_NATIVE_ID) continue + expect(isValidRecipient('avalanche', token.id)).toBe(false) + expect(isValidRecipient('monad', token.id.toLowerCase())).toBe(false) + } + } + for (const token of SUI_TOKENS) { + const address = normalizeSuiAddress(token.id.split('::')[0] as string) + expect(isValidRecipient('sui', address)).toBe(false) + } + }) +}) + +describe('sendRefusal', () => { + it.each([ + ['solana', 'SOL'], + ['sui', 'SUI'], + ['near', 'NEAR'], + ['near', 'USDT'], + ] as const)('refuses to move %s %s, naming what can be sent', (chain, symbol) => { + const to = { solana: SOL_TO, sui: SUI_TO, near: NEAR_TO }[chain] + const said = sendRefusal(chain, symbol, to) + expect(said).toContain(`Sending ${symbol}`) + expect(said).toContain('USDC') + expect(said).toMatch(/ Nothing was signed or sent\.$/) + }) + + it.each([ + ['solana', 'USDC', SOL_TO], + ['solana', 'JitoSOL', SOL_TO], + ['solana', 'ONyc', SOL_TO], + ['sui', 'USDsui', SUI_TO], + ['near', 'USDC', NEAR_TO], + ['avalanche', 'AVAX', EVM_TO], + ['avalanche', 'USDT', EVM_TO], + ['monad', 'MON', EVM_TO], + ['monad', 'USDT0', EVM_TO], + ] as const)('lets %s send %s', (chain, symbol, to) => { + expect(sendRefusal(chain, symbol, to)).toBeUndefined() + }) + + it('refuses a person’s own wallet, whatever the casing of a hex address', () => { + expect(sendRefusal('avalanche', 'USDC', EVM_ME.toLowerCase(), EVM_ME)).toMatch(/your own/) + expect(sendRefusal('solana', 'USDC', SOL_ME, SOL_ME)).toMatch(/your own/) + }) +}) + +describe('followOnRefusal for a send', () => { + const venuesHere = [{ kinds: ['swap'] as const }] + + it('refuses a recipient that is not an address here, before any swap', () => { + const said = followOnRefusal( + 'solana', + 'Solana', + { kind: 'send', recipient: 'alice' }, + venuesHere + ) + expect(said).toMatch(/Solana/) + expect(said).toMatch(/ Nothing was signed or sent\.$/) + }) + + it('refuses a token this chain can’t send when it knows the swap’s output', () => { + const said = followOnRefusal( + 'solana', + 'Solana', + { kind: 'send', recipient: SOL_TO }, + venuesHere, + 'SOL' + ) + expect(said).toContain('Sending SOL') + }) + + it('passes a good address', () => { + expect( + followOnRefusal('solana', 'Solana', { kind: 'send', recipient: SOL_TO }, venuesHere, 'USDC') + ).toBeUndefined() + }) +}) + +describe('parseChainAction reads a send', () => { + it('keeps the recipient’s case', () => { + expect(parseChainAction(`send 5 USDC to ${SOL_TO}`)).toEqual({ + kind: 'send', + amount: '5', + tokenIn: 'USDC', + recipient: SOL_TO, + }) + expect(parseChainAction(`Please transfer 1,000.5 usdc to ${EVM_TO}.`)).toEqual({ + kind: 'send', + amount: '1000.5', + tokenIn: 'usdc', + recipient: EVM_TO, + }) + }) + + it('reads a chain’s name as its token', () => { + expect(parseChainAction(`send 2 solana to ${SOL_TO}`)).toMatchObject({ tokenIn: 'SOL' }) + }) + + it('leaves the dot that ends a sentence off a NEAR account', () => { + expect(parseChainAction('send 5 USDC to bob.near.')).toMatchObject({ recipient: 'bob.near' }) + }) + + it('reads nothing that has no token or no address', () => { + expect(parseChainAction('send 5 to bob.near')).toBeNull() + expect(parseChainAction('send 5 USDC')).toBeNull() + expect(parseChainAction('send 5 USDC to bob.near and carol.near')).toBeNull() + }) +}) + +describe('a send on Solana', () => { + const venue = createSendVenue('solana') + + it('builds one transfer of that token and amount to that recipient, with the note', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + expect(prepared).toMatchObject({ + venueId: 'send', + venueName: 'Wallet transfer', + kind: 'send', + pay: { amount: '5', symbol: 'USDC' }, + }) + expect(prepared.summary).toContain(SOL_TO) + expect(prepared.notes).toContain(SEND_NOTE) + expect(SEND_NOTE).toBe("Transfers can't be undone. Check the address.") + expect(prepared.notes.join(' ')).toMatch(/rent/) + expect(() => venue.assertSafe(prepared.signing, SOL_ME, prepared.context)).not.toThrow() + }) + + it('has no quote, and is called what the brief says', () => { + expect(venue.id).toBe('send') + expect(venue.name).toBe('Wallet transfer') + expect(venue.kinds).toEqual(['send']) + expect(venue.quote).toBeUndefined() + }) + + it('refuses another recipient, amount, token and trailing data', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + const other = async (over: Partial): Promise => + (await venue.prepare(request({ recipient: SOL_TO, ...over }), SOL_ME)).signing + for (const payload of [ + await other({ recipient: SOL_THIEF }), + await other({ amount: '6' }), + await other({ tokenIn: 'USDT' }), + { family: 'solana', transaction: solanaBytes({ extra: true }) } as const, + ]) { + const said = refusal(() => venue.assertSafe(payload, SOL_ME, prepared.context)) + expect(said).toMatch(/ Nothing was sent\.$/) + expect(said).not.toMatch(/signed/) + expect(said).not.toMatch(WORDS_FOR_A_DEPOSIT) + } + }) + + it('refuses a recipient that is a token account, in words for a send', async () => { + sim.accountOwner = TOKEN_PROGRAM + const said = await refusalOf(venue.prepare(request({ recipient: SOL_TO }), SOL_ME)) + expect(said).toMatch(/token account/) + expect(said).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + describe('a recipient that is a program', () => { + beforeEach(() => { + sim.accountOwner = 'BPFLoaderUpgradeab1e11111111111111111111111' + sim.accountExecutable = true + }) + + it('is refused at the build and before a swap, since the funds would be stranded', async () => { + const said = await refusalOf(venue.prepare(request({ recipient: SOL_TO }), SOL_ME)) + expect(said).toMatch(/a program/) + expect(said).toMatch(/ Nothing was signed or sent\.$/) + expect(said).not.toMatch(WORDS_FOR_A_DEPOSIT) + await refusalOf( + assertFollowOnCanRun( + moduleFor('solana'), + { kind: 'send', tokenIn: 'USDC', recipient: SOL_TO }, + SOL_ME + ) + ) + }) + + it('changes nothing on Weave’s deposit path', async () => { + const payload = await solanaDeposit.build({ + account: SOL_ME, + depositAddress: SOL_TO, + memo: undefined, + amount: '5', + }) + expect(payload.family).toBe('solana') + }) + }) + + it('takes a wallet with an account that is not a program', async () => { + sim.accountOwner = SYSTEM_PROGRAM + await expect(venue.prepare(request({ recipient: SOL_TO }), SOL_ME)).resolves.toMatchObject({ + kind: 'send', + }) + }) + + it('refuses native SOL plainly', async () => { + expect( + await refusalOf(venue.prepare(request({ recipient: SOL_TO, tokenIn: 'SOL' }), SOL_ME)) + ).toContain('Sending SOL') + }) + + it('refuses an EVM address and a token mint as recipient', async () => { + await refusalOf(venue.prepare(request({ recipient: EVM_TO }), SOL_ME)) + await refusalOf(venue.prepare(request({ recipient: SOL_USDC.id }), SOL_ME)) + }) +}) + +describe('a browser that changes what it sends back, on Solana', () => { + const venue = createSendVenue('solana') + + async function submit(transaction: string, context: Record | undefined) { + return (await venue.submit?.({ + account: SOL_ME, + venueId: venue.id, + signed: { family: 'solana', signedTransaction: signedBytes(transaction) }, + ...(context !== undefined ? { context } : {}), + })) as SubmitResult + } + + it('sends what was issued', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + const result = await submit(solanaBytes(), prepared.context) + expect(result).toMatchObject({ ok: true }) + expect(sim.rpc).toContain('sendTransaction') + }) + + it('refuses a swapped recipient in the context, and sends nothing', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + const context = { ...prepared.context, recipient: SOL_THIEF } + expect( + refusal(() => + venue.assertSafe( + { family: 'solana', transaction: solanaBytes({ to: SOL_THIEF }) }, + SOL_ME, + context + ) + ) + ).toMatch(/not what this app issued/) + const result = await submit(solanaBytes({ to: SOL_THIEF }), context) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(sim.rpc).not.toContain('sendTransaction') + }) + + it('refuses a swapped recipient in the bytes, and sends nothing', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + const result = await submit(solanaBytes({ to: SOL_THIEF }), prepared.context) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(sim.rpc).not.toContain('sendTransaction') + if (!result.ok) expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + it('refuses a context proved for another account, or with no proof, or a 1Click proof', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + const payload = { family: 'solana', transaction: solanaBytes() } as const + expect(() => venue.assertSafe(payload, SOL_THIEF, prepared.context)).toThrow(ChainActionRefused) + const { proof: _gone, ...unproved } = prepared.context as Record + expect(() => venue.assertSafe(payload, SOL_ME, unproved)).toThrow(ChainActionRefused) + expect(() => venue.assertSafe(payload, SOL_ME, undefined)).toThrow(ChainActionRefused) + const context = prepared.context as Record + const oneClick = createHmac('sha256', 'x'.repeat(48)) + .update( + `oneclick-origin:${JSON.stringify(['send', SOL_ME, context['token'], context['amount'], SOL_TO, ''])}` + ) + .digest('base64url') + expect(() => venue.assertSafe(payload, SOL_ME, { ...context, proof: oneClick })).toThrow( + ChainActionRefused + ) + }) + + it('refuses everything without the secret, since no proof can be issued or read', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + vi.stubEnv('AUTH_SECRET', '') + await refusalOf(venue.prepare(request({ recipient: SOL_TO }), SOL_ME)) + expect(() => + venue.assertSafe({ family: 'solana', transaction: solanaBytes() }, SOL_ME, prepared.context) + ).toThrow(ChainActionRefused) + const result = await submit(solanaBytes(), prepared.context) + expect(result).toMatchObject({ ok: false }) + expect(sim.rpc).not.toContain('sendTransaction') + }) + + it('keeps the network’s sentence about the fee, which says what to do', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + sim.send = () => ({ + error: { code: -32002, message: 'x', data: { err: 'InsufficientFundsForFee' } }, + }) + const result = await submit(solanaBytes(), prepared.context) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + if (!result.ok) expect(result.error).toMatch(/enough SOL to pay the network fee/) + }) + + it('keeps the network’s sentence about a low balance', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + sim.send = () => ({ + error: { code: -32002, message: 'x', data: { logs: ['Transfer: insufficient funds'] } }, + }) + const result = await submit(solanaBytes(), prepared.context) + if (!result.ok) expect(result.error).toMatch(/balance is too low/) + }) + + it('rewrites only what speaks of prices', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + sim.send = () => ({ error: { code: -32002, message: 'x', data: { logs: ['custom error'] } } }) + const result = await submit(solanaBytes(), prepared.context) + expect(result).toMatchObject({ ok: false }) + if (!result.ok) { + expect(result.error).not.toMatch(/price/i) + expect(result.error).toMatch(/This transfer did not go through/) + } + }) + + it('reports a send that may have landed as pending, without a deposit’s words', async () => { + const prepared = await venue.prepare(request({ recipient: SOL_TO }), SOL_ME) + sim.send = () => { + throw new Error('connection broke') + } + const result = await submit(solanaBytes(), prepared.context) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) { + expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + expect(result.error).toMatch(/don't send it again/i) + } + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) +}) + +describe('a send on Sui', () => { + const venue = createSendVenue('sui') + + it('builds one transfer and checks it', async () => { + const prepared = await venue.prepare(request({ recipient: SUI_TO }), SUI_ME) + expect(prepared.summary).toContain(SUI_TO) + expect(prepared.notes).toContain(SEND_NOTE) + expect(() => venue.assertSafe(prepared.signing, SUI_ME, prepared.context)).not.toThrow() + }) + + it('refuses another recipient, amount, token and trailing data', async () => { + const prepared = await venue.prepare(request({ recipient: SUI_TO }), SUI_ME) + const other = async (over: Partial): Promise => + (await venue.prepare(request({ recipient: SUI_TO, ...over }), SUI_ME)).signing + for (const payload of [ + await other({ recipient: SUI_THIEF }), + await other({ amount: '6' }), + { family: 'sui', transaction: await suiBytes({ extra: true }) } as const, + ]) { + expect(refusal(() => venue.assertSafe(payload, SUI_ME, prepared.context))).not.toMatch( + WORDS_FOR_A_DEPOSIT + ) + } + const usdt = await venue.prepare(request({ recipient: SUI_TO, tokenIn: 'USDT' }), SUI_ME) + // The bytes name a coin by id alone, so another token shows at the amount's proof or at submit. + expect(() => venue.assertSafe(usdt.signing, SUI_ME, usdt.context)).not.toThrow() + expect(usdt.context?.['token']).not.toBe(prepared.context?.['token']) + core.getObjects.mockImplementation(async ({ objectIds }: { objectIds: string[] }) => ({ + objects: objectIds.map((id) => coinObject(id, tokenBySymbol(SUI_TOKENS, 'USDT')?.id)), + })) + const result = (await venue.submit?.({ + account: SUI_ME, + venueId: venue.id, + signed: { family: 'sui', transaction: await suiBytes(), signature: 'sig' }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + })) as SubmitResult + expect(result).toMatchObject({ ok: false }) + expect(core.executeTransaction).not.toHaveBeenCalled() + }) + + it('refuses native SUI plainly, and words a thin wallet for a send', async () => { + expect( + await refusalOf(venue.prepare(request({ recipient: SUI_TO, tokenIn: 'SUI' }), SUI_ME)) + ).toContain('Sending SUI') + core.listCoins.mockResolvedValue({ objects: [], hasNextPage: false, cursor: null }) + core.getBalance.mockResolvedValue({ balance: { balance: '0' } }) + const said = await refusalOf(venue.prepare(request({ recipient: SUI_TO }), SUI_ME)) + expect(said).toMatch(/holds less USDC/) + expect(said).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + it('refuses a Solana address', async () => { + await refusalOf(venue.prepare(request({ recipient: SOL_TO }), SUI_ME)) + }) + + it('says a failed read after signing sent nothing, and claims nothing about signing', async () => { + const prepared = await venue.prepare(request({ recipient: SUI_TO }), SUI_ME) + core.getObjects.mockRejectedValue(new Error('rpc blip')) + const result = (await venue.submit?.({ + account: SUI_ME, + venueId: venue.id, + signed: { family: 'sui', transaction: await suiBytes(), signature: 'sig' }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + })) as SubmitResult + expect(result).toEqual({ + ok: false, + error: 'This transfer was not sent, so nothing left your wallet. You can try again.', + }) + expect(core.executeTransaction).not.toHaveBeenCalled() + }) + + it('rewrites a failure on the network, which a trade’s or deposit’s words would misname', async () => { + const prepared = await venue.prepare(request({ recipient: SUI_TO }), SUI_ME) + core.executeTransaction.mockResolvedValue({ + FailedTransaction: { digest: 'DIGESTx', effects: { status: { success: false } } }, + }) + const result = (await venue.submit?.({ + account: SUI_ME, + venueId: venue.id, + signed: { family: 'sui', transaction: await suiBytes(), signature: 'sig' }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + })) as SubmitResult + expect(result).toMatchObject({ ok: false }) + if (!result.ok) expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + it('refuses a browser’s swapped recipient, in the context or in the bytes', async () => { + const prepared = await venue.prepare(request({ recipient: SUI_TO }), SUI_ME) + const bytes = await suiBytes({ to: SUI_THIEF }) + const payload = { family: 'sui', transaction: bytes } as const + expect(() => + venue.assertSafe(payload, SUI_ME, { ...prepared.context, recipient: SUI_THIEF }) + ).toThrow(/not what this app issued/) + expect(() => venue.assertSafe(payload, SUI_ME, prepared.context)).toThrow(ChainActionRefused) + const result = (await venue.submit?.({ + account: SUI_ME, + venueId: venue.id, + signed: { family: 'sui', transaction: bytes, signature: 'sig' }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + })) as SubmitResult + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(core.executeTransaction).not.toHaveBeenCalled() + }) +}) + +describe.each([ + ['avalanche', AVALANCHE_TOKENS, 'AVAX', 43114], + ['monad', MONAD_TOKENS, 'MON', 143], +] as const)('a send on %s', (chain, tokens, native, chainId) => { + const venue = createSendVenue(chain) + const usdc = tokenBySymbol(tokens, 'USDC') as ChainToken + + it('builds one ERC-20 transfer, and refuses another of anything', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO }), EVM_ME) + expect(prepared.signing).toMatchObject({ family: 'evm', chainId, to: usdc.id, value: '0' }) + expect(prepared.summary).toContain(EVM_TO) + expect(prepared.notes).toContain(SEND_NOTE) + expect(() => venue.assertSafe(prepared.signing, EVM_ME, prepared.context)).not.toThrow() + + const other = async (over: Partial): Promise => + (await venue.prepare(request({ recipient: EVM_TO, ...over }), EVM_ME)).signing + const signing = prepared.signing as Extract + for (const payload of [ + await other({ recipient: EVM_THIEF }), + await other({ amount: '6' }), + await other({ tokenIn: native }), + { ...signing, data: `${signing.data}00` }, + ]) { + expect(refusal(() => venue.assertSafe(payload, EVM_ME, prepared.context))).not.toMatch( + WORDS_FOR_A_DEPOSIT + ) + } + }) + + it('moves the native coin to the recipient, with no data', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO, tokenIn: native }), EVM_ME) + expect(prepared.signing).toEqual({ + family: 'evm', + chainId, + to: EVM_TO, + data: '0x', + value: '5000000000000000000', + }) + expect(() => venue.assertSafe(prepared.signing, EVM_ME, prepared.context)).not.toThrow() + }) + + it('refuses a token contract, a wrong checksum and a Solana address', async () => { + await refusalOf(venue.prepare(request({ recipient: usdc.id }), EVM_ME)) + await refusalOf(venue.prepare(request({ recipient: `0x${'B2'.repeat(19)}b2` }), EVM_ME)) + await refusalOf(venue.prepare(request({ recipient: SOL_TO }), EVM_ME)) + }) + + it('refuses more than the wallet holds, in words for a send', async () => { + rpc.tokenBalance = async () => BigInt(1) + const said = await refusalOf(venue.prepare(request({ recipient: EVM_TO }), EVM_ME)) + expect(said).toMatch(/holds less USDC/) + expect(said).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) +}) + +describe('a browser that changes what it sends back, on EVM', () => { + const venue = createSendVenue('avalanche') + + async function submit(context: Record | undefined): Promise { + return (await venue.submit?.({ + account: EVM_ME, + venueId: venue.id, + signed: { family: 'evm', hash: EVM_HASH }, + ...(context !== undefined ? { context } : {}), + })) as SubmitResult + } + + const paid = (to: string) => ({ + status: 'success', + from: EVM_ME, + to: AVAX_USDC.id, + logs: [transferLog(to, BigInt(5_000_000))], + }) + + it('confirms the issued transfer from the chain', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO }), EVM_ME) + rpc.waitForReceipt = async () => paid(EVM_TO) + await expect(submit(prepared.context)).resolves.toMatchObject({ ok: true, hash: EVM_HASH }) + }) + + it('never confirms a recipient swapped in the context, and holds the latch', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO }), EVM_ME) + rpc.waitForReceipt = async () => paid(EVM_THIEF) + const result = await submit({ ...prepared.context, recipient: EVM_THIEF }) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + expect(afterSubmitting(result, 'evm')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('never confirms a chain that paid someone else', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO }), EVM_ME) + rpc.waitForReceipt = async () => paid(EVM_THIEF) + const result = await submit(prepared.context) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + it('never confirms a native send to someone else', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO, tokenIn: 'AVAX' }), EVM_ME) + rpc.waitForReceipt = async () => ({ status: 'success', from: EVM_ME, to: EVM_THIEF, logs: [] }) + rpc.transaction = async () => ({ value: BigInt('5000000000000000000'), input: '0x' }) + await expect(submit(prepared.context)).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it('reads as pending without the secret, since the wallet has already sent', async () => { + const prepared = await venue.prepare(request({ recipient: EVM_TO }), EVM_ME) + vi.stubEnv('AUTH_SECRET', '') + await expect(submit(prepared.context)).resolves.toMatchObject({ ok: false, pending: true }) + }) +}) + +describe('a send on NEAR', () => { + const venue = createSendVenue('near') + + it('builds one ft_transfer on USDC to a named account, with no storage action', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + expect(prepared.signing).toEqual({ + family: 'near', + transactions: [ + { + receiverId: USDC_NEAR, + actions: [ + { + type: 'FunctionCall', + params: { + methodName: 'ft_transfer', + args: { receiver_id: NEAR_TO, amount: '5000000' }, + gas: TRANSFER_GAS.toString(), + deposit: '1', + }, + }, + ], + }, + ], + }) + expect(prepared.summary).toContain(NEAR_TO) + expect(prepared.notes).toContain(SEND_NOTE) + expect(() => venue.assertSafe(prepared.signing, NEAR_ME, prepared.context)).not.toThrow() + }) + + it('leaves a 1Click deposit’s check as strict as it was, whatever a venue passes it', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + const asVenue: (p: SigningPayload, a: string, c?: Record) => void = + assertOneClickSafe + expect(() => asVenue(prepared.signing, NEAR_ME, { depositAddress: 'x' })).toThrow( + /does not go to a deposit address/ + ) + }) + + it('takes an implicit account too', async () => { + const prepared = await venue.prepare(request({ recipient: 'a'.repeat(64) }), NEAR_ME) + expect(() => venue.assertSafe(prepared.signing, NEAR_ME, prepared.context)).not.toThrow() + }) + + it('refuses a receiver not registered with the token, in a plain sentence', async () => { + vi.mocked(isRegistered).mockResolvedValue(false) + const said = await refusalOf(venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME)) + expect(said).toContain(NEAR_TO) + expect(said).toMatch(/registered/) + expect(said).toMatch(/ Nothing was signed or sent\.$/) + expect(said).not.toContain('—') + }) + + it('refuses when registration can’t be read, rather than guess', async () => { + vi.mocked(isRegistered).mockRejectedValue(new Error('rpc down')) + const said = await refusalOf(venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME)) + expect(said).toMatch(/could not be read/) + }) + + it('refuses another recipient, amount, token, a storage action and extra arguments', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + const base = prepared.signing as Extract + const tx = base.transactions[0] as (typeof base.transactions)[number] + const action = tx.actions[0] as (typeof tx.actions)[number] + const withArgs = (args: Record): SigningPayload => ({ + family: 'near', + transactions: [{ ...tx, actions: [{ ...action, params: { ...action.params, args } }] }], + }) + const storage = { + type: 'FunctionCall' as const, + params: { + methodName: 'storage_deposit', + args: { account_id: NEAR_TO, registration_only: true }, + gas: TRANSFER_GAS.toString(), + deposit: '1250000000000000000000', + }, + } + for (const payload of [ + withArgs({ receiver_id: NEAR_THIEF, amount: '5000000' }), + withArgs({ receiver_id: NEAR_TO, amount: '6000000' }), + withArgs({ receiver_id: NEAR_TO, amount: '5000000', memo: 'x' }), + { family: 'near', transactions: [{ ...tx, receiverId: USDT_NEAR }] } as const, + { family: 'near', transactions: [{ ...tx, actions: [storage, action] }] } as const, + { family: 'near', transactions: [tx, tx] } as const, + ] as SigningPayload[]) { + expect(() => venue.assertSafe(payload, NEAR_ME, prepared.context)).toThrow(ChainActionRefused) + } + }) + + it('refuses every token but USDC, and native NEAR plainly', async () => { + expect( + await refusalOf(venue.prepare(request({ recipient: NEAR_TO, tokenIn: 'NEAR' }), NEAR_ME)) + ).toContain('Sending NEAR') + expect( + await refusalOf(venue.prepare(request({ recipient: NEAR_TO, tokenIn: 'USDT' }), NEAR_ME)) + ).toContain('Sending USDT') + }) + + it('refuses a token contract as recipient', async () => { + await refusalOf(venue.prepare(request({ recipient: WRAP_NEAR }), NEAR_ME)) + await refusalOf(venue.prepare(request({ recipient: USDC_NEAR }), NEAR_ME)) + }) + + async function submit(context: Record | undefined): Promise { + return (await venue.submit?.({ + account: NEAR_ME, + venueId: venue.id, + signed: { family: 'near', hashes: [NEAR_HASH] }, + ...(context !== undefined ? { context } : {}), + })) as SubmitResult + } + + const called = (to: string, amount = '5000000') => ({ + signer: NEAR_ME, + receiver: USDC_NEAR, + calls: [{ method: 'ft_transfer', args: { receiver_id: to, amount }, deposit: '1' }], + }) + + it('confirms what the chain shows', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + vi.mocked(waitForTransaction).mockResolvedValue({ state: 'success', ...called(NEAR_TO) }) + await expect(submit(prepared.context)).resolves.toMatchObject({ ok: true, hash: NEAR_HASH }) + }) + + it('never confirms a recipient swapped in the context', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + vi.mocked(waitForTransaction).mockResolvedValue({ state: 'success', ...called(NEAR_THIEF) }) + const result = await submit({ ...prepared.context, recipient: NEAR_THIEF }) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + it('reads a chain that paid someone else as pending, since the wallet has already sent', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + vi.mocked(waitForTransaction).mockResolvedValue({ state: 'success', ...called(NEAR_THIEF) }) + const result = await submit(prepared.context) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).not.toMatch(WORDS_FOR_A_DEPOSIT) + }) + + it('reads a failed hash as pending too: it might not be this transfer', async () => { + const prepared = await venue.prepare(request({ recipient: NEAR_TO }), NEAR_ME) + vi.mocked(waitForTransaction).mockResolvedValue({ state: 'failed', ...called(NEAR_THIEF) }) + await expect(submit(prepared.context)).resolves.toMatchObject({ ok: false, pending: true }) + }) +}) + +describe('prepareChainAction on a send', () => { + it('needs a recipient for a send, and takes none for anything else', async () => { + const solana = moduleFor('solana') + await refusalOf(prepareChainAction(solana, request({}), SOL_ME)) + const said = await refusalOf( + prepareChainAction( + moduleFor('solana', { + venues: [{ ...createSendVenue('solana'), kinds: ['swap'] }, createSendVenue('solana')], + defaultVenue: { swap: 'send', send: 'send' }, + }), + { kind: 'swap', amount: '5', tokenIn: 'USDC', tokenOut: 'USDT', recipient: SOL_TO }, + SOL_ME + ) + ) + expect(said).toMatch(/only a send/i) + }) + + it('applies the dollar cap, in words for a transfer', async () => { + const said = await refusalOf( + prepareChainAction( + moduleFor('solana'), + request({ amount: '600', recipient: SOL_TO }), + SOL_ME, + {} + ) + ) + expect(said).toMatch(/caps each transfer/) + expect(said).not.toMatch(/trade/) + expect(sim.rpc).toEqual([]) + }) + + it('refuses a token it can’t price, in words for a transfer', async () => { + const said = await refusalOf( + prepareChainAction( + moduleFor('solana', { priceUsd: async () => undefined }), + request({ recipient: SOL_TO }), + SOL_ME, + {} + ) + ) + expect(said).toMatch(/This transfer could not be valued/) + }) + + it('keeps the fee reserve on a native send', async () => { + const said = await refusalOf( + prepareChainAction( + moduleFor('avalanche', { + balances: async () => ({ + tokens: [ + { + symbol: 'AVAX', + id: EVM_NATIVE_ID, + amount: '5', + decimals: 18, + native: true, + listed: true, + }, + ], + complete: true, + }), + }), + request({ recipient: EVM_TO, tokenIn: 'AVAX' }), + EVM_ME, + {} + ) + ) + expect(said).toMatch(/Keep about 0\.01 AVAX/) + }) + + describe('what the wallet holds', () => { + const held = (amount: string, id = AVAX_USDC.id, symbol = 'USDC') => ({ + symbol, + id, + amount, + decimals: 6, + native: false, + listed: true, + }) + const send = (amount: string, chain: ChainModule): Promise => + prepareChainAction(chain, request({ amount, recipient: EVM_TO }), EVM_ME, {}) + + it('refuses a known shortfall in a plain sentence', async () => { + const chain = moduleFor('avalanche', { + balances: async () => ({ tokens: [held('5')], complete: false }), + }) + expect(await refusalOf(send('50', chain))).toBe( + 'You hold 5 USDC, which is less than 50. Nothing was signed or sent.' + ) + }) + + it('reads the token by id, never by a symbol anyone can copy', async () => { + const chain = moduleFor('avalanche', { + balances: async () => ({ + tokens: [held('999', `0x${'9'.repeat(40)}`), held('5')], + complete: false, + }), + }) + expect(await refusalOf(send('50', chain))).toMatch(/You hold 5 USDC/) + }) + + it('counts a token missing from an answer that lists everything as none held', async () => { + const chain = moduleFor('avalanche', { + balances: async () => ({ tokens: [], complete: true }), + }) + expect(await refusalOf(send('50', chain))).toMatch(/You hold 0 USDC/) + }) + + it('passes exactly what is held', async () => { + const chain = moduleFor('avalanche', { + balances: async () => ({ tokens: [held('50')], complete: false }), + }) + await expect(send('50', chain)).resolves.toMatchObject({ kind: 'send' }) + }) + + it('passes when the read fails or says nothing about the token', async () => { + for (const balances of [ + async () => { + throw new Error('rpc down') + }, + async () => ({ tokens: [], complete: false }), + async () => ({ tokens: [], complete: true, unread: ['USDC'] }), + ]) { + await expect(send('50', moduleFor('avalanche', { balances }))).resolves.toMatchObject({ + kind: 'send', + }) + } + }) + + it('passes a read that takes too long, without waiting on the chain', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const working = send( + '50', + moduleFor('avalanche', { balances: () => new Promise(() => undefined) }) + ) + await vi.advanceTimersByTimeAsync(3_000) + await expect(working).resolves.toMatchObject({ kind: 'send' }) + } finally { + vi.useRealTimers() + } + }) + + it('reads a native send once, leaving it to the reserve check', async () => { + const balances = vi.fn(async () => ({ tokens: [], complete: false })) + await prepareChainAction( + moduleFor('avalanche', { balances }), + request({ tokenIn: 'AVAX', recipient: EVM_TO }), + EVM_ME, + {} + ) + expect(balances).toHaveBeenCalledTimes(1) + }) + + it('reads nothing for anything but a send', async () => { + const balances = vi.fn(async () => ({ tokens: [], complete: true })) + const venue = { + id: 'lend', + name: 'Lend', + kinds: ['supply'] as const, + prepare: async () => ({ kind: 'supply' }) as PreparedAction, + assertSafe: () => undefined, + } + await prepareChainAction( + moduleFor('avalanche', { balances, venues: [venue], defaultVenue: { supply: 'lend' } }), + { kind: 'supply', amount: '50', tokenIn: 'USDC' }, + EVM_ME, + {} + ) + expect(balances).not.toHaveBeenCalled() + }) + }) + + it('says native SOL can’t move before it says anything about the reserve', async () => { + const balances = vi.fn(async () => ({ tokens: [], complete: true })) + const said = await refusalOf( + prepareChainAction( + moduleFor('solana', { balances }), + request({ recipient: SOL_TO, tokenIn: 'SOL' }), + SOL_ME, + {} + ) + ) + expect(said).toContain('Sending SOL') + expect(balances).not.toHaveBeenCalled() + }) + + it('builds and checks through the whole path', async () => { + const prepared: PreparedAction = await prepareChainAction( + moduleFor('avalanche'), + request({ recipient: EVM_TO }), + EVM_ME, + {} + ) + expect(prepared.kind).toBe('send') + expect(prepared.context?.['recipient']).toBe(EVM_TO) + }) +}) + +describe('the race', () => { + it('goes straight to the card for a typed send, and only for a send', () => { + expect(racesFirst({ kind: 'send' })).toBe(false) + for (const kind of ['swap', 'supply', 'stake'] as const) { + expect(racesFirst({ kind })).toBe(true) + } + }) + + it('never takes a send', async () => { + await expect( + quoteChainRoutes(moduleFor('solana'), request({ recipient: SOL_TO }), SOL_ME) + ).resolves.toEqual([]) + }) +}) + +describe('where the send venue is offered', () => { + const KEY = { AUTH_SECRET: 'x'.repeat(48) } + + it('is on every chain when a proof can be issued, as the default for a send', () => { + expect(buildSolanaVenues(KEY).map((v) => v.id)).toContain('send') + expect(buildSuiVenues(KEY).map((v) => v.id)).toContain('send') + expect(buildNearVenues(KEY).map((v) => v.id)).toContain('send') + expect(buildEvmVenues('avalanche', KEY).map((v) => v.id)).toContain('send') + expect(buildEvmVenues('monad', KEY).map((v) => v.id)).toContain('send') + for (const chain of [solanaModule, suiModule, nearModule, avalancheModule, monadModule]) { + expect(chain.defaultVenue.send).toBe('send') + } + }) + + it('is on no chain without the secret', () => { + for (const list of [ + buildSolanaVenues({}), + buildSuiVenues({}), + buildNearVenues({}), + buildEvmVenues('avalanche', {}), + buildEvmVenues('monad', { AUTH_SECRET: 'short' }), + ]) { + expect(list.map((v) => v.id)).not.toContain('send') + } + }) + + it('is not an app, so the Apps list has no entry and needs no logo', () => { + expect(venues.map((v) => v.id)).not.toContain('send') + }) +}) + +describe('assertFollowOnCanRun', () => { + const step = (over: Partial[1]>) => + ({ kind: 'send', tokenIn: 'USDC', ...over }) as Parameters[1] + + it('passes a send that could run, and reads nothing it does not need', async () => { + await expect( + assertFollowOnCanRun(moduleFor('solana'), step({ recipient: SOL_TO }), SOL_ME) + ).resolves.toBeUndefined() + }) + + it('refuses before the swap a NEAR receiver not registered with the token', async () => { + vi.mocked(isRegistered).mockResolvedValue(false) + const said = await refusalOf( + assertFollowOnCanRun(moduleFor('near'), step({ recipient: NEAR_TO }), NEAR_ME) + ) + expect(said).toMatch(/registered/) + }) + + it('refuses before the swap a Solana recipient that is a token account', async () => { + sim.accountOwner = TOKEN_PROGRAM + const said = await refusalOf( + assertFollowOnCanRun(moduleFor('solana'), step({ recipient: SOL_TO }), SOL_ME) + ) + expect(said).toMatch(/token account/) + }) + + it('refuses a token that can’t be sent, and no send venue at all', async () => { + expect( + await refusalOf( + assertFollowOnCanRun( + moduleFor('solana'), + step({ recipient: SOL_TO, tokenIn: 'SOL' }), + SOL_ME + ) + ) + ).toContain('Sending SOL') + expect( + await refusalOf( + assertFollowOnCanRun( + moduleFor('solana', { venues: [] }), + step({ recipient: SOL_TO }), + SOL_ME + ) + ) + ).toMatch(/Nothing here can send/) + }) + + it('reads the real venues: what each supply and stake takes after a swap', async () => { + const onSui = suiModule + await expect( + assertFollowOnCanRun(onSui, step({ kind: 'supply' }), SUI_ME) + ).resolves.toBeUndefined() + await expect( + assertFollowOnCanRun(onSui, step({ kind: 'stake', tokenIn: 'SUI' }), SUI_ME) + ).resolves.toBeUndefined() + expect(await refusalOf(assertFollowOnCanRun(onSui, step({ kind: 'stake' }), SUI_ME))).toMatch( + /SpringSui takes SUI/ + ) + expect( + await refusalOf( + assertFollowOnCanRun(onSui, step({ kind: 'supply', tokenIn: 'USDT' }), SUI_ME) + ) + ).toMatch(/NAVI takes USDC/) + + const onSolana = solanaModule + await expect( + assertFollowOnCanRun(onSolana, step({ kind: 'supply', tokenIn: 'USDT' }), SOL_ME) + ).resolves.toBeUndefined() + expect( + await refusalOf( + assertFollowOnCanRun( + onSolana, + step({ kind: 'supply', tokenIn: 'USDT', venue: 'kamino' }), + SOL_ME + ) + ) + ).toMatch(/Kamino takes USDC/) + expect( + await refusalOf( + assertFollowOnCanRun(onSolana, step({ kind: 'supply', venue: 'nowhere' }), SOL_ME) + ) + ).toMatch(/"nowhere" can't supply here/) + + await expect( + assertFollowOnCanRun(nearModule, step({ kind: 'supply', tokenIn: 'USDT' }), NEAR_ME) + ).resolves.toBeUndefined() + }) + + it('refuses a stake after a swap on NEAR, whose output may come wrapped', async () => { + const said = await refusalOf( + assertFollowOnCanRun(nearModule, step({ kind: 'stake', tokenIn: 'NEAR' }), NEAR_ME) + ) + expect(said).toMatch(/wNEAR/) + expect(said).toMatch(/ Nothing was signed or sent\.$/) + }) + + it('refuses a token this chain does not list', async () => { + await refusalOf( + assertFollowOnCanRun( + moduleFor('solana'), + step({ recipient: SOL_TO, tokenIn: 'SCAM' }), + SOL_ME + ) + ) + }) +}) + +function post(body: unknown): Request { + return new Request('http://localhost/x', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('the prepare route', () => { + const params = { params: Promise.resolve({ chain: 'avalanche' }) } + + beforeEach(() => { + vi.mocked(chainModule).mockReturnValue(moduleFor('avalanche')) + }) + + it('carries the recipient exactly as typed', async () => { + const reply = await PREPARE( + post({ + account: EVM_ME, + request: { kind: 'send', amount: '5', tokenIn: 'USDC', recipient: EVM_TO }, + }), + params + ) + expect(reply.status).toBe(200) + const { prepared } = (await reply.json()) as { prepared: PreparedAction } + expect(prepared.context?.['recipient']).toBe(EVM_TO) + }) + + it('refuses a send with no recipient', async () => { + const reply = await PREPARE( + post({ account: EVM_ME, request: { kind: 'send', amount: '5', tokenIn: 'USDC' } }), + params + ) + expect(reply.status).toBe(422) + }) + + it('refuses a recipient on anything but a send', async () => { + const reply = await PREPARE( + post({ + account: EVM_ME, + request: { kind: 'supply', amount: '5', tokenIn: 'USDC', recipient: EVM_TO }, + }), + params + ) + expect(reply.status).toBe(422) + }) + + it('refuses an oversized recipient at the door', async () => { + const reply = await PREPARE( + post({ + account: EVM_ME, + request: { kind: 'send', amount: '5', tokenIn: 'USDC', recipient: 'x'.repeat(129) }, + }), + params + ) + expect(reply.status).toBe(400) + }) +}) + +describe('the follow-on route', () => { + const params = { params: Promise.resolve({ chain: 'near' }) } + + beforeEach(() => { + vi.mocked(chainModule).mockReturnValue(moduleFor('near')) + }) + + it('answers ok for a step that can run', async () => { + const reply = await FOLLOW_ON( + post({ + account: NEAR_ME, + step: { kind: 'send', tokenIn: 'USDC', recipient: NEAR_TO }, + }), + params + ) + expect(reply.status).toBe(200) + await expect(reply.json()).resolves.toEqual({ ok: true }) + }) + + it('gives the sentence for one that can’t, ending the way every refusal does', async () => { + vi.mocked(isRegistered).mockResolvedValue(false) + const reply = await FOLLOW_ON( + post({ + account: NEAR_ME, + step: { kind: 'send', tokenIn: 'USDC', recipient: NEAR_TO }, + }), + params + ) + expect(reply.status).toBe(422) + const { error } = (await reply.json()) as { error: string } + expect(error).toMatch(/registered/) + expect(error).toMatch(/ Nothing was signed or sent\.$/) + }) + + it('adds the ending where a shared sentence has none', async () => { + const reply = await FOLLOW_ON( + post({ account: NEAR_ME, step: { kind: 'stake', tokenIn: 'NEAR' } }), + params + ) + expect(reply.status).toBe(422) + const { error } = (await reply.json()) as { error: string } + expect(error).toMatch(/ Nothing was signed or sent\.$/) + }) + + it('takes no swap', async () => { + const reply = await FOLLOW_ON( + post({ account: NEAR_ME, step: { kind: 'swap', tokenIn: 'USDC' } }), + params + ) + expect(reply.status).toBe(400) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-sequence-final.test.ts b/apps/dapp/lib/__tests__/chain-sequence-final.test.ts new file mode 100644 index 00000000..928cc3e1 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-sequence-final.test.ts @@ -0,0 +1,784 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { STEP_CARD_EXPIRED, stepErrorMessage } from '../agents/chain-race' +import { chainCapabilities } from '../chains/capabilities' +import { chainHelp, helpForUnread, RATE_LIMITED } from '../chains/explain' +import { looksLikeChainAction } from '../chains/parse-action' +import { + chainRouteFromReading, + messageStartsAction, + parseChainSequence, + readChainMessage, + requestSaid, + sequenceFromReading, + type ReadSwap, +} from '../chains/parse-sequence' +import { followOnRefusal, type ChainSequenceRequest } from '../chains/sequence' +import { + keepsRun, + moveOnSecond, + stepOnItsOwn, + secondStepLabel, + stepAmount, + type SequenceStage, +} from '../chains/sequence-flow' +import { afterSigning, QUOTE_EXPIRED, quoteExpiredBeforeSigning } from '../chains/signing-flow' +import type { ChainWithActions, PreparedAction, SigningPayload } from '../chains/types' +import { fetchOrchestrate } from '../chat/orchestrate-client' +import { unavailableMessage } from '../chat/orchestrate-copy' +import { readingFromArguments } from '../parse-intent-llm' +import { currencyNamedIn, routeCashout } from '../weave/parse-cashout' + +const ADDRESS = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' +const SEND: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'send', recipient: ADDRESS }, +} +const CASHOUT: ChainSequenceRequest = { ...SEND, followOn: { kind: 'cashout' } } + +const read = ( + over: Partial = {} +): ReadSwap & { action: string } => ({ + action: 'swap', + tokenIn: 'SOL', + tokenOut: 'USDC', + amountUsd: 5, + amountStated: true, + amountIsUsd: false, + followOn: null, + ...over, +}) + +describe('1. step 2 card that expired', () => { + it('sends the person to the button, not to their request, once the swap has settled', () => { + const said = stepErrorMessage(QUOTE_EXPIRED, true) + expect(said).toBe(STEP_CARD_EXPIRED) + expect(said).toBe( + "This step's card expired. Press the button above for a fresh one. The swap stands, so don't send your request again." + ) + }) + + it('leaves step 1 as it was', () => { + expect(stepErrorMessage(QUOTE_EXPIRED, false)).toBe(QUOTE_EXPIRED) + }) + + it('has the button there: an expired card proves nothing moved, so the run is back at ready', () => { + const following = { stage: 'following', settled: true, opened: true } as const + expect(moveOnSecond(following, 'failed', false, SEND, '5', 'send').stage).toBe('ready') + }) + + it('never tells the person to send a new message after a wallet that did not answer', () => { + const step = afterSigning({ ok: false, reason: 'rejected' }, 'evm') + if (step.next !== 'unknown') throw new Error('expected unknown') + const said = stepErrorMessage(step.error, true) as string + expect(said).not.toMatch(/new message|Press the button/) + expect(said).toMatch(/Check your wallet's activity first/) + expect(said).toMatch(/The swap stands, so don't send your request again\./) + // No button in this state, which is why this sentence offers none. + const following = { stage: 'following', settled: true, opened: true } as const + expect(moveOnSecond(following, 'pending', true, SEND, '5', 'send').stage).toBe('unsettled') + }) + + it('keeps the same wallet sentence on step 1, where a new message is right', () => { + const step = afterSigning({ ok: false, reason: 'rejected' }, 'evm') + if (step.next !== 'unknown') throw new Error('expected unknown') + expect(stepErrorMessage(step.error, false)).toMatch(/send a new message to start again/) + }) +}) + +describe('2. ordinary words reach the model', () => { + it.each([ + 'swap some sol', + 'i want to swap 5 sol to usdc then cash out', + 'can i cash out here', + 'send my usdc to my friend', + 'swap 5', + 'whats my wallet balance', + ])('%s goes to the model', (text) => { + expect(readChainMessage(text, 'solana', 'Solana')).toEqual({ kind: 'model' }) + }) + + it('still reads a complete sentence without a model', () => { + expect(readChainMessage('swap 5 SOL to USDC', 'solana', 'Solana')).toMatchObject({ + kind: 'action', + action: { amount: '5', tokenIn: 'SOL', tokenOut: 'USDC' }, + }) + expect(readChainMessage('swap 5 SOL to USDC then cash out', 'solana', 'Solana')).toMatchObject({ + kind: 'sequence', + request: { followOn: { kind: 'cashout' } }, + }) + expect(readChainMessage('swap 5 USDC to SOL then cash out', 'solana', 'Solana')).toMatchObject({ + kind: 'refusal', + }) + }) + + it('answers with what works when the model could not read a sentence with a verb', () => { + const capabilities = chainCapabilities('solana') ?? [] + const help = chainHelp('Solana', capabilities) + expect(helpForUnread('unreadable', ['swap some sol'], 'Solana', capabilities)).toBe(help) + expect(helpForUnread('unreadable', ['how are you'], 'Solana', capabilities)).toBeUndefined() + expect(looksLikeChainAction('swap some sol')).toBe(true) + }) +}) + +describe('3. a currency as the token out', () => { + it.each(['solana', 'sui', 'avalanche'] as const)('is a cash-out sequence on %s', (chain) => { + expect(readChainMessage('change 5 SOL into naira', chain, chain)).toEqual({ + kind: 'cashout', + route: { kind: 'sequence', token: 'SOL', amount: '5', currency: 'NGN' }, + }) + expect(readChainMessage('swap 5 SOL to naira', chain, chain)).toMatchObject({ + route: { currency: 'NGN' }, + }) + expect(readChainMessage('convert 5 sol to kes', chain, chain)).toMatchObject({ + route: { kind: 'sequence', currency: 'KES' }, + }) + expect(readChainMessage('convert 5 sol to kenyan shillings', chain, chain)).toMatchObject({ + route: { kind: 'sequence', currency: 'KES' }, + }) + expect(readChainMessage('change my 5 sol into kes', chain, chain)).toMatchObject({ + route: { kind: 'sequence', amount: '5', currency: 'KES' }, + }) + }) + + it('opens the form for USDC, since no swap is needed', () => { + expect(readChainMessage('change 5 USDC into naira', 'solana', 'Solana')).toEqual({ + kind: 'cashout', + route: { kind: 'open', amount: '5', currency: 'NGN' }, + }) + }) + + it('takes a venue off the end, which a cash-out has no use for', () => { + expect(readChainMessage('swap 5 SOL to naira on jupiter', 'solana', 'Solana')).toMatchObject({ + route: { kind: 'sequence', currency: 'NGN' }, + }) + }) + + it('says it for a fiat that is not paid', () => { + expect(readChainMessage('swap 5 SOL to euros', 'solana', 'Solana')).toEqual({ + kind: 'cashout', + route: { kind: 'currency', currency: 'EUR' }, + }) + }) + + it.each([ + ['near', 'NEAR'], + ['monad', 'Monad'], + ] as const)('is refused before anything starts on %s', (chain, name) => { + const refusal = followOnRefusal(chain, name, { kind: 'cashout' }, []) + expect(refusal).toBeDefined() + expect(readChainMessage('change 5 SOL into naira', chain, name)).toEqual({ + kind: 'refusal', + refusal, + }) + expect(readChainMessage('swap 5 SOL to euros', chain, name)).toEqual({ + kind: 'refusal', + refusal, + }) + }) + + it('leaves a real token alone', () => { + expect(readChainMessage('swap 5 SOL to USDT', 'solana', 'Solana')).toMatchObject({ + kind: 'action', + action: { tokenOut: 'USDT' }, + }) + }) + + it('asks the model when the words give it a fiat on both sides', () => { + expect(readChainMessage('swap 5 eur to naira', 'solana', 'Solana')).toEqual({ kind: 'model' }) + }) +}) + +describe('4. a follow-on the server cannot honour', () => { + const lendArgs = { + action: 'swap', + tokenIn: 'SOL', + tokenOut: 'USDC', + amountUsd: 5, + amountStated: true, + followOn: 'lend', + followOnVenue: '', + } + + it('is reported off Stellar: no venues, no anchors, an unknown venue', () => { + const off = { reportRefusedFollowOn: true } + expect( + readingFromArguments(lendArgs, { ...off, allowedVenues: [], allowedAnchors: [] }) + ).toMatchObject({ followOn: null, followOnRefused: { kind: 'lend', venue: '' } }) + expect( + readingFromArguments( + { ...lendArgs, followOn: 'offramp' }, + { ...off, allowedVenues: [], allowedAnchors: [] } + ) + ).toMatchObject({ followOn: null, followOnRefused: { kind: 'offramp', venue: '' } }) + expect( + readingFromArguments( + { ...lendArgs, followOnVenue: 'aave' }, + { ...off, allowedVenues: ['kamino'], allowedAnchors: [] } + ) + ).toMatchObject({ followOn: null, followOnRefused: { kind: 'lend', venue: 'aave' } }) + }) + + it('leaves the reading as it was on Stellar', () => { + const reading = readingFromArguments( + { ...lendArgs, followOnVenue: 'aave' }, + { allowedVenues: ['blend'], allowedAnchors: [] } + ) + expect(reading?.followOn).toBeNull() + expect(reading).not.toHaveProperty('followOnRefused') + expect( + readingFromArguments( + { ...lendArgs, followOnVenue: 'de findex' }, + { allowedVenues: ['defindex'], allowedAnchors: [] } + )?.followOn + ).toBeNull() + }) + + it('reports nothing when no follow-on was asked for or it can run', () => { + const off = { reportRefusedFollowOn: true, allowedVenues: [], allowedAnchors: [] } + expect(readingFromArguments({ ...lendArgs, followOn: 'none' }, off)).not.toHaveProperty( + 'followOnRefused' + ) + expect( + readingFromArguments(lendArgs, { ...off, allowedVenues: ['kamino'] }) + ).not.toHaveProperty('followOnRefused') + }) + + it('matches a venue whatever its spaces and hyphens, off Stellar', () => { + const options = { + reportRefusedFollowOn: true, + allowedVenues: ['jupiter-lend', 'kamino'], + allowedAnchors: [], + } + for (const followOnVenue of ['jupiter lend', 'Jupiter-Lend', 'jupiterlend']) { + expect(readingFromArguments({ ...lendArgs, followOnVenue }, options)?.followOn).toEqual({ + kind: 'lend', + venue: 'jupiter-lend', + venueNamed: true, + }) + } + }) + + it('reaches the chat through the client, and only when the server sent it', async () => { + const answer = (extra: object): typeof fetch => + (async () => + new Response( + JSON.stringify({ + outcome: 'act', + action: 'swap', + tokenIn: 'SOL', + tokenOut: 'USDC', + instruction: 'swap 5 SOL to USDC', + followOn: null, + ...extra, + }) + )) as unknown as typeof fetch + const turns = [{ role: 'user' as const, content: 'x' }] + const refused = await fetchOrchestrate( + turns, + [], + 'solana', + answer({ followOnRefused: { kind: 'lend', venue: 'aave' } }) + ) + expect(refused).toMatchObject({ read: { followOnRefused: { kind: 'lend', venue: 'aave' } } }) + const plain = await fetchOrchestrate(turns, [], 'stellar', answer({})) + if (plain.outcome !== 'act') throw new Error('expected act') + expect(plain.read).not.toHaveProperty('followOnRefused') + }) + + it('becomes a sequence, never a plain swap, so the chat refuses it before step 1', () => { + const said = ['swap 5 sol to usdc then cash out'] + const route = chainRouteFromReading( + read({ followOnRefused: { kind: 'offramp', venue: '' } }), + 'Swap 5 SOL to USDC', + said + ) + expect(route).toMatchObject({ sequence: { followOn: { kind: 'cashout' } } }) + if (route === null || !('sequence' in route)) throw new Error('expected a sequence') + for (const [chain, name] of [ + ['near', 'NEAR'], + ['monad', 'Monad'], + ] as [ChainWithActions, string][]) { + expect(followOnRefusal(chain, name, route.sequence.followOn, [])).toMatch( + /Nothing was signed or sent\./ + ) + } + }) + + it('carries a deposit on no venue to the refusal that names the chain', () => { + const route = chainRouteFromReading( + read({ followOnRefused: { kind: 'lend', venue: '' } }), + 'Swap 5 SOL to USDC', + ['swap 5 sol to usdc then deposit'] + ) + if (route === null || !('sequence' in route)) throw new Error('expected a sequence') + expect(followOnRefusal('monad', 'Monad', route.sequence.followOn, [])).toMatch(/Monad/) + }) + + it('keeps a venue the person named, for the server to refuse, and drops one they did not', () => { + const named = read({ followOnRefused: { kind: 'lend', venue: 'aave' } }) + expect( + chainRouteFromReading(named, 'x', ['swap 5 sol to usdc then deposit on aave']) + ).toMatchObject({ sequence: { followOn: { kind: 'supply', venue: 'aave' } } }) + expect(chainRouteFromReading(named, 'x', ['swap 5 sol to usdc then deposit'])).toEqual({ + sequence: { + swap: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'supply' }, + }, + }) + }) + + it('refuses an anchor the person named that cash-outs here do not use', () => { + const out = chainRouteFromReading( + read({ followOnRefused: { kind: 'offramp', venue: 'moneygram' } }), + 'x', + ['swap 5 sol to usdc then cash out with moneygram'] + ) + expect(out).toEqual({ + refusal: "Cash-outs here don't run through moneygram. Nothing was signed or sent.", + }) + }) + + it('is not put off by a "then" with nothing after it', () => { + expect( + chainRouteFromReading(read(), 'Swap 5 SOL to USDC', ['swap 5 sol to usdc', 'ok then']) + ).toMatchObject({ + action: { amount: '5' }, + }) + }) + + it('refuses a swap with more after it that nothing here read', () => { + for (const [instruction, said] of [ + ['Swap 5 SOL to USDC, then send to my friend', 'swap 5 sol to usdc, then send to my friend'], + ['Swap 5 SOL to USDC', 'i want to swap 5 sol to usdc then send it to my friend'], + ] as const) { + const out = chainRouteFromReading(read(), instruction, [said]) + expect(out).toHaveProperty('refusal') + expect((out as { refusal: string }).refusal).toMatch(/Nothing was signed or sent\.$/) + } + }) +}) + +describe('5. a venue the model chose for step 2', () => { + it('is dropped, and the step kept', () => { + const sentence = 'Swap 5 SOL to USDC, then deposit on Kamino' + expect(chainRouteFromReading(read(), sentence, ['swap 5 sol to usdc then deposit'])).toEqual({ + sequence: { + swap: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'supply' }, + }, + }) + const stake = 'Swap 5 SOL to USDC, then stake on Jito' + expect(chainRouteFromReading(read(), stake, ['swap 5 sol to usdc then stake'])).toMatchObject({ + sequence: { followOn: { kind: 'stake' } }, + }) + }) + + it('stays when the person named it', () => { + const sentence = 'Swap 5 SOL to USDC, then deposit on Jupiter Lend' + expect( + chainRouteFromReading(read(), sentence, ['swap 5 sol to usdc then deposit on jupiter-lend']) + ).toMatchObject({ sequence: { followOn: { kind: 'supply', venue: 'Jupiter Lend' } } }) + }) +}) + +describe('6. offramp on NEAR', () => { + it('passes the chain, so NEAR is not told to swap first', () => { + const chat = readFileSync(join(__dirname, '../../components/intents/intent-chat.tsx'), 'utf8') + expect(chat).toContain('unsupportedTokenReply(route.token, slug)') + expect(chat).not.toContain('unsupportedTokenReply(route.token)') + }) + + it('reads "offramp 5 near" as that refusal', () => { + const route = routeCashout({ unsupportedToken: 'NEAR', amount: '5' }, 'near') + expect(route).toEqual({ kind: 'token', token: 'NEAR' }) + }) +}) + +describe('7. more arriving than the swap should bring', () => { + const quotedNote = 'More arrived than this swap should bring, so this uses the quoted 5 USDC.' + + it('uses the quoted amount for a send, deposit or stake past 2%', () => { + expect(stepAmount(SEND, '205', '5')).toEqual({ amount: '5', note: quotedNote }) + for (const followOn of [{ kind: 'supply' }, { kind: 'stake' }] as const) { + expect(stepAmount({ ...SEND, followOn }, '205', '5')).toEqual({ + amount: '5', + note: quotedNote, + }) + } + expect(stepAmount(SEND, '5.2', '5')).toMatchObject({ amount: '5' }) + }) + + it('uses what was measured up to 2% over, and below the quote', () => { + expect(stepAmount(SEND, '5.1', '5')).toEqual({ amount: '5.1' }) + expect(stepAmount(SEND, '4.9', '5')).toEqual({ amount: '4.9' }) + expect(stepAmount(SEND, '5', '5')).toEqual({ amount: '5' }) + }) + + it('uses what was measured with no usable quote', () => { + expect(stepAmount(SEND, '205', undefined)).toEqual({ amount: '205' }) + expect(stepAmount(SEND, '205', 'about 5')).toEqual({ amount: '205' }) + expect(stepAmount(SEND, '205', '0')).toEqual({ amount: '205' }) + }) + + it('leaves a cash-out alone, since its form can be edited', () => { + expect(stepAmount(CASHOUT, '205', '5')).toEqual({ amount: '205' }) + }) + + it('compares at the finer of the two figures', () => { + expect(stepAmount(SEND, '5.100001', '5')).toMatchObject({ note: expect.any(String) }) + expect(stepAmount(SEND, '5.1', '5.000001')).toEqual({ amount: '5.1' }) + }) + + it('makes the button say the amount it will use', () => { + const { amount } = stepAmount(SEND, '205', '5') + expect(secondStepLabel(SEND, amount)).toBe('Send 5 USDC') + }) +}) + +describe('8. a card past its deadline', () => { + const NOW = Date.parse('2026-11-04T12:00:00.000Z') + const card = (family: SigningPayload['family']): Pick => ({ + signing: { family } as SigningPayload, + context: { deadline: '2026-11-04T11:00:00.000Z' }, + }) + + it('stops on a wallet that sends for itself', () => { + expect(quoteExpiredBeforeSigning(card('near'), NOW)).toBe(true) + expect(quoteExpiredBeforeSigning(card('evm'), NOW)).toBe(true) + }) + + it('is left to the server where it sends the bytes', () => { + expect(quoteExpiredBeforeSigning(card('solana'), NOW)).toBe(false) + expect(quoteExpiredBeforeSigning(card('sui'), NOW)).toBe(false) + }) +}) + +describe('9. a message and the run', () => { + const live: SequenceStage[] = ['measuring', 'ready', 'next', 'following'] + const over: SequenceStage[] = ['swap', 'stranded', 'unsettled', 'followed', 'done'] + + it('keeps a run with a step still to take, unless the message starts an action', () => { + for (const stage of live) { + expect(keepsRun(stage, false)).toBe(true) + expect(keepsRun(stage, true)).toBe(false) + } + }) + + it('ends one with nothing left to keep, as before', () => { + for (const stage of over) expect(keepsRun(stage, false)).toBe(false) + expect(keepsRun(undefined, false)).toBe(false) + }) + + const of = (text: string): boolean => + messageStartsAction({ kind: 'pass' }, readChainMessage(text, 'solana', 'Solana')) + + it('reads what is typed: a question starts nothing, and an instruction does', () => { + expect(of("what's my balance?")).toBe(false) + expect(of('what can i do')).toBe(false) + expect(of('i want to go jogging')).toBe(false) + expect(of('swap some sol')).toBe(false) + expect(of('swap 5 SOL to USDC')).toBe(true) + expect(of('swap 5 SOL to USDC then deposit')).toBe(true) + expect(of('change 5 SOL into naira')).toBe(true) + }) + + it('reads a refusal as starting nothing, so the button stays', () => { + expect(of('swap 5 USDC to SOL then cash out')).toBe(false) + expect(of('swap 5 SOL to euros')).toBe(false) + expect(messageStartsAction({ kind: 'currency', currency: 'EUR' }, null)).toBe(false) + expect(messageStartsAction({ kind: 'token', token: 'SOL' }, null)).toBe(false) + }) + + it('reads a cash-out that opens or starts as an action', () => { + expect(messageStartsAction({ kind: 'ask' }, null)).toBe(true) + expect(messageStartsAction({ kind: 'open', currency: 'NGN' }, null)).toBe(true) + expect(messageStartsAction({ kind: 'sequence', token: 'SOL', amount: '5' }, null)).toBe(true) + }) +}) + +describe('10. the last figure stated', () => { + const swap = (amount: string) => + ({ kind: 'swap', amount, tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }) as const + + it('takes a correction, and refuses the figure it replaced', () => { + const said = ['flip 5 SOL to USDC', 'no make it 2'] + expect(requestSaid(swap('2'), said)).toBe(true) + expect(requestSaid(swap('5'), said)).toBe(false) + }) + + it('takes what is spent over what is held', () => { + const said = ['I have 5 SOL, flip 3 of it to USDC'] + expect(requestSaid(swap('3'), said)).toBe(true) + expect(requestSaid(swap('5'), said)).toBe(false) + }) + + it('still reads figures given over several turns', () => { + expect(requestSaid(swap('5'), ['swap some sol', '5', 'to usdc'])).toBe(true) + expect(requestSaid(swap('5'), ['swap sol to usdc', '5'])).toBe(true) + }) + + it('still refuses the reverse of what was said, corrected or not', () => { + const reversed = ['flip 5 USDC to SOL', 'no make it 2'] + expect(requestSaid(swap('2'), reversed)).toBe(false) + expect(requestSaid({ ...swap('2'), tokenIn: 'USDC', tokenOut: 'SOL' }, reversed)).toBe(true) + expect(requestSaid(swap('3'), ['I have 5 USDC, flip 3 of it to SOL'])).toBe(false) + }) + + it('takes a correction that names the token, the right way round only', () => { + expect(requestSaid(swap('3'), ['swap 5 sol to usdc', 'actually 3 sol'])).toBe(true) + expect(requestSaid(swap('5'), ['swap 5 sol to usdc', 'actually 3 sol'])).toBe(false) + expect(requestSaid(swap('3'), ['swap 5 usdc to sol', 'actually 3 sol'])).toBe(false) + }) + + it('reads a bare figure backwards as nothing', () => { + expect(requestSaid(swap('5'), ['swap 5 to sol from usdc'])).toBe(false) + expect(requestSaid(swap('5'), ['5', 'to sol from usdc'])).toBe(false) + expect(requestSaid(swap('3'), ['flip 3 of it to sol from usdc'])).toBe(false) + }) + + it('still refuses a figure that stands next to another token', () => { + expect(requestSaid(swap('5'), ['swap 5 USDT to SOL then USDC'])).toBe(false) + }) + + it('refuses what it cannot decide: a figure spent out of a figure held', () => { + const said = ['swap 2 of my 5 SOL to USDC'] + expect(requestSaid(swap('2'), said)).toBe(false) + expect(requestSaid(swap('5'), said)).toBe(false) + }) + + it('does not count a figure for another unit as the last one', () => { + expect(requestSaid(swap('5'), ['swap 5 SOL to about 800 USDC'])).toBe(true) + expect(requestSaid(swap('5'), ['swap 5 SOL to USDC with 2% slippage'])).toBe(true) + }) +}) + +describe('11. sentences that say what is true', () => { + it('never gives "swap 5 USDC to USDC" as an example', () => { + const offramp = sequenceFromReading( + read({ tokenIn: 'USDC', followOn: { kind: 'offramp', venue: 'weave' }, amountStated: false }), + ['x'] + ) as { refusal: string } + expect(offramp.refusal).not.toMatch(/USDC to USDC/) + expect(offramp.refusal).toContain('"cash out 5 USDC"') + const plain = chainRouteFromReading( + read({ action: 'supply', tokenIn: 'USDC' }), + 'Supply 5 USDC', + ['supply it'] + ) as { refusal: string } + expect(plain.refusal).not.toMatch(/USDC to USDC/) + expect(plain.refusal).toContain('"deposit 5 USDC"') + const other = sequenceFromReading( + read({ + tokenIn: 'USDC', + tokenOut: 'SOL', + followOn: { kind: 'lend', venue: 'kamino' }, + amountStated: false, + }), + ['x'] + ) as { refusal: string } + expect(other.refusal).toContain('"swap 5 USDC to SOL then deposit"') + }) + + it('labels each follow-on in its example as what it is', () => { + const typed = ['swap 9 sol to usdc'] + const example = (sentence: string): string => + (chainRouteFromReading(read(), sentence, typed) as { refusal: string }).refusal + expect(example('Swap 5 SOL to USDC, then deposit')).toContain('then deposit"') + expect(example('Swap 5 SOL to USDC, then stake')).toContain('then stake"') + expect(example('Swap 5 SOL to USDC, then cash out')).toContain('then cash out"') + expect(example(`Swap 5 SOL to USDC, then send to ${ADDRESS}`)).toContain( + 'then send to an address"' + ) + }) + + it('says deposit where a person said deposit', () => { + expect(followOnRefusal('monad', 'Monad', { kind: 'supply' }, [])).toBe( + 'Nothing on Monad can take a deposit yet. Nothing was signed or sent.' + ) + expect(followOnRefusal('monad', 'Monad', { kind: 'stake' }, [])).toBe( + 'Nothing on Monad can stake yet. Nothing was signed or sent.' + ) + }) +}) + +describe('12. a currency named in the person words', () => { + const cash = { kind: 'offramp', venue: 'weave' } + + it('reaches the cash-out, so it is not asked again', () => { + expect(sequenceFromReading(read({ followOn: cash }), ['turn 5 SOL into naira'])).toMatchObject({ + followOn: { kind: 'cashout', currency: 'NGN' }, + }) + }) + + it('reaches it from a written-out sentence too', () => { + expect( + chainRouteFromReading(read(), 'Swap 5 SOL to USDC, then cash out', ['turn 5 SOL into kes']) + ).toMatchObject({ sequence: { followOn: { kind: 'cashout', currency: 'KES' } } }) + }) + + it('is asked when none was named, or two were', () => { + expect(sequenceFromReading(read({ followOn: cash }), ['turn 5 SOL into cash'])).toEqual({ + swap: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'cashout' }, + }) + expect(currencyNamedIn(['turn 5 SOL into naira', 'or kes'])).toBeUndefined() + }) + + it('reads whole words, in any turn and any case', () => { + expect(currencyNamedIn(['turn 5 SOL', 'to my NAIRA'])).toBe('NGN') + expect(currencyNamedIn(['Kenyan shillings please'])).toBe('KES') + expect(currencyNamedIn(['bakes a cake'])).toBeUndefined() + expect(currencyNamedIn(['my nigerian friend'])).toBeUndefined() + }) +}) + +describe('13. a wallet switch that waits', () => { + it('says in the comment what happens when the wait ends', () => { + const chat = readFileSync(join(__dirname, '../../components/intents/intent-chat.tsx'), 'utf8') + expect(chat).not.toContain("`ready` after it is only a button the new wallet can't use") + }) +}) + +describe('the parsers a sentence goes through first', () => { + it('keeps a typed follow-on the strict shape cannot read out of a sequence', () => { + expect(parseChainSequence('swap 5 sol to usdc then send to my friend')).toBeNull() + }) +}) + +describe('a comma that is not a thousands comma', () => { + const swap = (amount: string) => + ({ kind: 'swap', amount, tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }) as const + + it.each([ + ['1,5', ['15', '1.5']], + ['0,5', ['5', '0.5']], + ['0,500', ['500', '0.5']], + ])('passes nothing on "%s", and refuses in words', (figure, amounts) => { + const said = [`swap ${figure} sol to usdc`] + for (const amount of amounts) { + expect(requestSaid(swap(amount), said)).toBe(false) + const out = chainRouteFromReading(read({ amountUsd: Number(amount) }), 'x', said) + expect(out).toHaveProperty('refusal') + } + }) + + it('reads a thousands comma', () => { + expect(requestSaid(swap('1000'), ['swap 1,000 sol to usdc'])).toBe(true) + expect(requestSaid(swap('1000.5'), ['swap 1,000.5 sol to usdc'])).toBe(true) + }) + + it('does not fall back to the figure before a correction it cannot read', () => { + expect(requestSaid(swap('5'), ['swap 5 sol to usdc', 'make it 1,5'])).toBe(false) + }) + + it('still reads a figure with a comma after it', () => { + expect(requestSaid(swap('5'), ['swap 5 sol, to usdc'])).toBe(true) + expect(requestSaid(swap('3'), ['I have 5, sol, flip 3 of it to USDC'])).toBe(true) + }) +}) + +describe('sent too many messages', () => { + const capabilities = chainCapabilities('solana') ?? [] + const answer = (retryAfter: number): typeof fetch => + (async () => + new Response(JSON.stringify({ error: 'rate_limited', retryAfter }), { + status: 429, + })) as unknown as typeof fetch + const turns = [{ role: 'user' as const, content: 'x' }] + + it('is told apart from a model that could not be reached', async () => { + expect(await fetchOrchestrate(turns, [], 'solana', answer(30))).toEqual({ + outcome: 'unavailable', + reason: 'rate_limited', + tokens: [], + }) + }) + + it('does not blame their words, on any message', () => { + expect(RATE_LIMITED).toBe("You're sending messages quickly. Wait a minute and try again.") + for (const said of ['swap some sol', 'how are you']) { + expect(helpForUnread('rate_limited', [said], 'Solana', capabilities)).toBe(RATE_LIMITED) + } + }) + + it('does not say a minute when the wait is the daily cap', async () => { + const reply = await fetchOrchestrate(turns, [], 'solana', answer(80_000)) + if (reply.outcome !== 'unavailable') throw new Error('expected unavailable') + const said = helpForUnread(reply.reason, ['swap some sol'], 'Solana', capabilities) + expect(said).not.toMatch(/minute/) + expect(said).toMatch(/today/) + }) + + it('reads the same on Stellar as any other failed read', async () => { + const reply = await fetchOrchestrate(turns, [], 'stellar', answer(30)) + if (reply.outcome !== 'unavailable') throw new Error('expected unavailable') + expect(unavailableMessage(reply.reason, reply.tokens)).toBe( + "I can't read that right now. Please try again in a moment." + ) + }) +}) + +describe('a step 2 wallet that gave no answer', () => { + const wallet = (): string => { + const step = afterSigning({ ok: false, reason: 'rejected' }, 'near') + if (step.next !== 'unknown') throw new Error('expected unknown') + return step.error + } + + it('gives a next step, in the words of the step', () => { + const said = stepErrorMessage(wallet(), true, stepOnItsOwn(SEND)) + expect(said).toContain( + 'Check your wallet. If nothing went out, you can type the step on its own, for example "send 5 USDC to an address".' + ) + expect(said).toContain("The swap stands, so don't send your request again.") + }) + + it('chooses the example by the follow-on and the token that arrived', () => { + expect(stepOnItsOwn({ ...SEND, followOn: { kind: 'supply' } })).toBe('deposit 5 USDC') + const toSol = { ...SEND.swap, tokenIn: 'USDC', tokenOut: 'SOL' } + expect(stepOnItsOwn({ swap: toSol, followOn: { kind: 'stake' } })).toBe('stake 5 SOL') + expect(stepOnItsOwn(CASHOUT)).toBeUndefined() + }) + + it('offers no example where there is none', () => { + expect(stepErrorMessage(wallet(), true)).not.toContain('for example') + }) + + it('leaves the other answers alone', () => { + expect(stepErrorMessage(QUOTE_EXPIRED, true, stepOnItsOwn(SEND))).toBe(STEP_CARD_EXPIRED) + expect(stepErrorMessage(wallet(), false, stepOnItsOwn(SEND))).toBe(wallet()) + }) +}) + +describe('the examples in a refusal', () => { + const example = (sentence: string, said: string): string => + (chainRouteFromReading(read(), 'Swap 5 SOL to USDC', [sentence, said]) as { refusal: string }) + .refusal + + it.each([ + ['swap 5 sol to usdc then cash out', 'then cash out"'], + ['swap 5 sol to usdc then off-ramp please', 'then cash out"'], + ['swap 5 sol to usdc then deposit it somewhere', 'then deposit"'], + ['swap 5 sol to usdc then stake it somewhere', 'then stake"'], + ['swap 5 sol to usdc then send it to my friend', 'then send to an address"'], + ])('keeps to the follow-on said in "%s"', (sentence, words) => { + expect(example(sentence, '')).toContain(words) + }) + + it('adds no follow-on it could not name', () => { + expect(example('swap 5 sol to usdc then dance', '')).toContain('"swap 5 SOL to USDC"') + }) + + it('gives a whole sentence on a USDC refusal, by what was asked', () => { + const usdc = read({ action: 'supply', tokenIn: 'USDC' }) + const refused = (instruction: string): string => + (chainRouteFromReading(usdc, instruction, ['do it']) as { refusal: string }).refusal + expect(refused('Supply 5 USDC')).toContain('"deposit 5 USDC"') + expect(refused('Stake 5 USDC')).toContain('"stake 5 USDC"') + expect(refused(`Send 5 USDC to ${ADDRESS}`)).toContain('"send 5 USDC to an address"') + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts new file mode 100644 index 00000000..6423ee85 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -0,0 +1,1275 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + CASHOUT_UNDER_WAY, + BALANCE_UNREAD, + BEFORE_RETRY_MS, + CASHOUT_NOT_STARTED, + MEASURE_ATTEMPTS, + MEASURE_INTERVAL_MS, + SWAP_NEXT_STEP, + TOO_SMALL_FOR_WEAVE, + UNCONFIRMED_SWAP, + beforeReadCounts, + cashoutSequence, + currencyStep, + deliveredLine, + endedNote, + holdingOf, + keepsBefore, + measureDelivered, + moveOnCard, + moveOnSecond, + nextRequest, + outputToken, + questionAnswerFrom, + questionChainRefusal, + quoteNote, + quoteStep, + readBeforeSwap, + readCashoutNote, + readFollowOnRefusal, + readHolding, + readyAction, + secondStepLabel, + secondStepPress, + sequenceOutcome, + sequenceWordsMatch, + stageAfterMeasure, + stageAfterSwap, + stageOnCard, + stageOnForm, + stepLabels, + stepMarks, + swapLine, + unmeasuredReply, +} from '../chains/sequence-flow' +import { strandedReply, type ChainSequenceRequest } from '../chains/sequence' +import type { WalletBalances } from '../chains/types' +import { accountMismatch } from '../weave/cashout-view' + +const ALICE = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' +const BOB = 'BPFLoaderUpgradeab1e11111111111111111111111' +const SOLANA_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +const USDC = { symbol: 'USDC', id: SOLANA_USDC, decimals: 6 } + +const REQUEST: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '0.5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'cashout', currency: 'KES' }, +} + +function balances(partial: Partial): WalletBalances { + return { tokens: [], complete: true, ...partial } +} + +function held(id: string, amount: string, symbol = 'USDC') { + return { symbol, id, amount, decimals: 6, native: false, listed: true } +} + +describe('stageAfterSwap', () => { + it('measures after settled', () => { + expect(stageAfterSwap('settled')).toBe('measuring') + expect(stageAfterSwap('settled', false)).toBe('measuring') + }) + + it.each(['idle', 'building', 'review', 'signing', 'submitting'] as const)( + 'has nothing to say while the card is %s', + (phase) => { + expect(stageAfterSwap(phase)).toBeUndefined() + expect(stageAfterSwap(phase, true)).toBeUndefined() + } + ) + + it('strands a pending step 1, latched or not', () => { + expect(stageAfterSwap('pending')).toBe('stranded') + expect(stageAfterSwap('pending', true)).toBe('stranded') + expect(stageAfterSwap('pending', false)).toBe('stranded') + }) + + it('strands a failure unless told a signature cannot have moved money', () => { + expect(stageAfterSwap('failed')).toBe('stranded') + expect(stageAfterSwap('failed', true)).toBe('stranded') + }) + + it('ends plainly on a failure that proves nothing moved', () => { + expect(stageAfterSwap('failed', false)).toBe('done') + }) +}) + +describe('stageOnCard', () => { + it('walks step 1 forward', () => { + expect(stageOnCard('swap', 'review', false)).toBe('swap') + expect(stageOnCard('swap', 'settled', true)).toBe('measuring') + expect(stageOnCard('swap', 'pending', true)).toBe('stranded') + expect(stageOnCard('swap', 'failed', true)).toBe('stranded') + expect(stageOnCard('swap', 'failed', false)).toBe('done') + }) + + it('lets a clean failure be retried on the same race, so it is not stuck', () => { + expect(stageOnCard('done', 'failed', false)).toBe('done') + expect(stageOnCard('done', 'idle', false)).toBe('done') + expect(stageOnCard('done', 'building', false)).toBe('swap') + expect(stageOnCard('done', 'review', false)).toBe('swap') + expect(stageOnCard('swap', 'settled', true)).toBe('measuring') + }) + + it('never moves on from what is already known, whatever the card does next', () => { + for (const stage of ['measuring', 'ready', 'next', 'stranded'] as const) { + for (const phase of ['idle', 'building', 'review', 'settled', 'pending', 'failed'] as const) { + expect(stageOnCard(stage, phase, true)).toBe(stage) + } + } + }) + + it('never reaches ready from a pending step 1, on any path', () => { + let stage = stageOnCard('swap', 'pending', true) + for (const phase of ['idle', 'pending', 'settled', 'review', 'building'] as const) { + stage = stageOnCard(stage, phase, true) + expect(stage).toBe('stranded') + } + }) +}) + +describe('moveOnCard', () => { + const START = { stage: 'swap', settled: false } as const + + it('stays put while the card is open', () => { + for (const phase of ['idle', 'building', 'review', 'signing', 'submitting'] as const) { + expect(moveOnCard(START, phase, true, true, REQUEST)).toBe(START) + } + }) + + it('measures after settled when the balance before the swap is known', () => { + expect(moveOnCard(START, 'settled', true, true, REQUEST)).toEqual({ + stage: 'measuring', + settled: true, + }) + }) + + it('goes straight to stranded after settled when it is not, guessing nothing', () => { + const moved = moveOnCard(START, 'settled', true, false, REQUEST) + expect(moved).toEqual({ + stage: 'stranded', + settled: true, + error: unmeasuredReply(REQUEST), + }) + expect(moved.error).toContain('The swap went through') + expect(moved.error).toContain('Nothing was paid out.') + expect(moved.error).not.toMatch(/\d/) + }) + + it('never says the swap went through for a pending step 1', () => { + const moved = moveOnCard(START, 'pending', true, true, REQUEST) + expect(moved).toEqual({ stage: 'stranded', settled: false, error: UNCONFIRMED_SWAP }) + expect(moved.error).not.toMatch(/went through/i) + }) + + it('never says it for a wallet error after sending either, and tells them to check', () => { + const moved = moveOnCard(START, 'failed', true, true, REQUEST) + expect(moved).toEqual({ stage: 'stranded', settled: false, error: UNCONFIRMED_SWAP }) + expect(moved.error).toMatch(/check your wallet/i) + }) + + it('ends plainly on a clean failure, then comes back up on a retry', () => { + const failed = moveOnCard(START, 'failed', false, false, REQUEST) + expect(failed).toEqual({ stage: 'done', settled: false, error: CASHOUT_NOT_STARTED }) + expect(failed.error).not.toMatch(/went through/i) + expect(moveOnCard(failed, 'idle', false, false, REQUEST)).toBe(failed) + const retried = moveOnCard(failed, 'review', false, true, REQUEST) + expect(retried).toEqual({ stage: 'swap', settled: false }) + expect(moveOnCard(retried, 'settled', true, true, REQUEST).stage).toBe('measuring') + }) + + it('leaves a swap that settled alone, whatever the card does next', () => { + const measuring = { stage: 'measuring', settled: true } as const + const finished = { stage: 'done', settled: true } as const + for (const phase of ['idle', 'building', 'review', 'failed', 'pending'] as const) { + expect(moveOnCard(measuring, phase, true, true, REQUEST)).toBe(measuring) + expect(moveOnCard(finished, phase, false, true, REQUEST)).toBe(finished) + } + }) + + it('never offers step 2 after a pending step 1, even if the card later says settled', () => { + const stranded = moveOnCard(START, 'pending', true, true, REQUEST) + expect(moveOnCard(stranded, 'settled', true, true, REQUEST)).toBe(stranded) + }) +}) + +describe('step 2 on its own card', () => { + const SEND: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '0.5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'send', recipient: ALICE }, + } + const OPENING = { stage: 'following', settled: true, opened: false } as const + const OPEN = { stage: 'following', settled: true, opened: true } as const + + it('does not read step 1’s ending, still on the card, as step 2’s', () => { + for (const phase of ['settled', 'pending', 'failed', 'idle'] as const) { + expect(moveOnSecond(OPENING, phase, true, SEND, '5', 'send')).toBe(OPENING) + expect(moveOnSecond(OPENING, phase, false, SEND, '5', 'send')).toBe(OPENING) + } + }) + + it('opens when its own build starts, and holds while the card is live', () => { + expect(moveOnSecond(OPENING, 'building', false, SEND, '5', 'send')).toEqual(OPEN) + for (const phase of ['building', 'review', 'signing', 'submitting'] as const) { + expect(moveOnSecond(OPEN, phase, true, SEND, '5', 'send')).toBe(OPEN) + expect(moveOnSecond(OPEN, phase, false, SEND, '5', 'send')).toBe(OPEN) + } + }) + + it('finishes on settled, keeping step 1 flagged settled and measuring nothing', () => { + const moved = moveOnSecond(OPEN, 'settled', true, SEND, '5', 'send') + expect(moved.stage).toBe('followed') + expect(moved.settled).toBe(true) + expect(moved.error).toBeUndefined() + }) + + it('ends on pending, saying step 1 went through and not to send again', () => { + const moved = moveOnSecond(OPEN, 'pending', true, SEND, '5', 'send') + expect(moved.stage).toBe('unsettled') + expect(moved.settled).toBe(true) + expect(moved.error).toContain('5 USDC') + expect(moved.error).toMatch(/check your wallet/i) + expect(moved.error).not.toMatch(/cash|paid out/i) + }) + + it('ends on a failure that can’t prove nothing moved', () => { + expect(moveOnSecond(OPEN, 'failed', true, SEND, '5', 'send').stage).toBe('unsettled') + expect(moveOnSecond(OPEN, 'idle', true, SEND, '5', 'send').stage).toBe('unsettled') + }) + + it('returns to the button on a failure that proves nothing moved, and on Cancel', () => { + expect(moveOnSecond(OPEN, 'failed', false, SEND, '5', 'send').stage).toBe('ready') + expect(moveOnSecond(OPEN, 'idle', false, SEND, '5', 'send').stage).toBe('ready') + }) + + it('does not tick step 2 for a card of another kind settling, and ends instead', () => { + for (const kind of ['swap', 'supply', 'stake', undefined] as const) { + const moved = moveOnSecond(OPEN, 'settled', true, SEND, '5', kind) + expect(moved.stage).toBe('unsettled') + expect(moved.error).toMatch(/check your wallet/i) + } + const deposit: ChainSequenceRequest = { ...SEND, followOn: { kind: 'supply' } } + expect(moveOnSecond(OPEN, 'settled', true, deposit, '5', 'supply').stage).toBe('followed') + expect(moveOnSecond(OPEN, 'settled', true, deposit, '5', 'swap').stage).toBe('unsettled') + const stake: ChainSequenceRequest = { ...SEND, followOn: { kind: 'stake' } } + expect(moveOnSecond(OPEN, 'settled', true, stake, '5', 'stake').stage).toBe('followed') + }) + + it('never brings the button back once step 2 has ended, whatever the card does', () => { + const ended = [ + moveOnSecond(OPEN, 'settled', true, SEND, '5', 'send'), + moveOnSecond(OPEN, 'pending', true, SEND, '5', 'send'), + moveOnSecond(OPEN, 'failed', true, SEND, '5', 'send'), + ] + for (const progress of ended) { + for (const phase of [ + 'idle', + 'building', + 'review', + 'signing', + 'submitting', + 'settled', + 'pending', + 'failed', + ] as const) { + for (const signed of [true, false]) { + expect(moveOnSecond(progress, phase, signed, SEND, '5', 'send')).toBe(progress) + expect(moveOnCard(progress, phase, signed, true, SEND)).toBe(progress) + } + } + } + }) + + it('walks a signed send through to its end without ever offering the button again', () => { + const phases = [ + ['building', false], + ['review', false], + ['signing', true], + ['submitting', true], + ['pending', true], + ['idle', true], + ['failed', false], + ['settled', false], + ] as const + let progress: Parameters[0] = OPENING + const seen: string[] = [] + for (const [phase, signed] of phases) { + progress = moveOnSecond(progress, phase, signed, SEND, '5', 'send') + seen.push(progress.stage) + } + expect(seen).not.toContain('ready') + expect(seen.at(-1)).toBe('unsettled') + }) + + it('a step 2 outcome starts no measure and no third step through moveOnCard', () => { + for (const phase of ['settled', 'pending', 'failed'] as const) { + expect(moveOnCard(OPEN, phase, true, true, SEND)).toBe(OPEN) + } + }) + + it('the cash-out form can’t move it', () => { + for (const stage of ['following', 'followed', 'unsettled'] as const) { + expect(stageOnForm(stage, { open: false, locked: false, paid: false })).toBe(stage) + } + }) +}) + +describe('nextRequest', () => { + const swap = { kind: 'swap', amount: '1', tokenIn: 'SOL', tokenOut: 'usdc' } as const + + it('prepares a send with the measured amount, the swap’s output and the recipient', () => { + expect(nextRequest({ swap, followOn: { kind: 'send', recipient: ALICE } }, '4.98', 6)).toEqual({ + kind: 'send', + amount: '4.98', + tokenIn: 'usdc', + recipient: ALICE, + }) + }) + + it('prepares a supply or stake at the venue named, or the default', () => { + expect(nextRequest({ swap, followOn: { kind: 'supply' } }, '4.98', 6)).toEqual({ + kind: 'supply', + amount: '4.98', + tokenIn: 'usdc', + }) + expect(nextRequest({ swap, followOn: { kind: 'supply', venue: 'Kamino' } }, '4.98', 6)).toEqual( + { kind: 'supply', amount: '4.98', tokenIn: 'usdc', venue: 'Kamino' } + ) + expect(nextRequest({ swap, followOn: { kind: 'stake' } }, '1', 9)).toEqual({ + kind: 'stake', + amount: '1', + tokenIn: 'usdc', + }) + }) + + it('floors what is past the token’s decimals, and never rounds up', () => { + expect( + nextRequest({ swap, followOn: { kind: 'send', recipient: ALICE } }, '1.1234569', 6)?.amount + ).toBe('1.123456') + expect(nextRequest({ swap, followOn: { kind: 'stake' } }, '2.50', 6)?.amount).toBe('2.5') + }) + + it('gives nothing to prepare when flooring leaves nothing, or for a cash-out', () => { + expect(nextRequest({ swap, followOn: { kind: 'stake' } }, '0.0000001', 6)).toBeUndefined() + expect(nextRequest({ swap, followOn: { kind: 'cashout' } }, '5', 6)).toBeUndefined() + expect(nextRequest({ swap, followOn: { kind: 'stake' } }, 'lots', 6)).toBeUndefined() + }) +}) + +describe('words for what follows a swap', () => { + const withKind = (followOn: ChainSequenceRequest['followOn']): ChainSequenceRequest => ({ + swap: { kind: 'swap', amount: '0.5', tokenIn: 'SOL', tokenOut: 'USDC' }, + followOn, + }) + const OTHERS = [ + withKind({ kind: 'supply' }), + withKind({ kind: 'stake' }), + withKind({ kind: 'send', recipient: ALICE }), + ] + + it('keeps every cash-out sentence as it was', () => { + expect( + moveOnCard({ stage: 'swap', settled: false }, 'pending', true, true, REQUEST).error + ).toBe( + "This swap is not confirmed, so the cash-out will not start. Check your wallet's activity before doing anything else. Nothing was paid out." + ) + expect( + moveOnCard({ stage: 'swap', settled: false }, 'failed', false, true, REQUEST).error + ).toBe('The cash-out did not start.') + expect(swapLine('review', 'unknown', undefined, REQUEST)).toBe( + 'I could not read your USDC balance, so if you sign this swap the cash-out will not follow. You would hold the USDC and can cash it out yourself.' + ) + expect(swapLine('review', 'known', TOO_SMALL_FOR_WEAVE, REQUEST)).toBe(TOO_SMALL_FOR_WEAVE) + expect(deliveredLine('5', REQUEST)).toBe('About 5 USDC reached your wallet.') + expect(deliveredLine('0.001', REQUEST)).toBe( + 'About 0.001 USDC reached your wallet, which is too small to cash out. Nothing was paid out.' + ) + }) + + it('never speaks of a cash-out or a payout for anything else', () => { + for (const request of OTHERS) { + const said = [ + moveOnCard({ stage: 'swap', settled: false }, 'pending', true, true, request).error, + moveOnCard({ stage: 'swap', settled: false }, 'failed', false, true, request).error, + swapLine('review', 'unknown', undefined, request), + swapLine('review', 'known', undefined, request), + deliveredLine('0.001', request), + sequenceOutcome('ready', '0.001', undefined, request), + unmeasuredReply(request), + ...stepLabels(request), + ].join(' ') + expect(said).not.toMatch(/cash|paid out|weave/i) + expect(said).not.toContain('—') + } + }) + + it('leaves out Weave’s size note, which is about cash-outs only', () => { + for (const request of OTHERS) { + expect(swapLine('review', 'known', TOO_SMALL_FOR_WEAVE, request)).toBe(SWAP_NEXT_STEP) + } + }) + + it('says what a pending swap means for each, without saying it went through', () => { + const moved = moveOnCard( + { stage: 'swap', settled: false }, + 'pending', + true, + true, + OTHERS[2] as ChainSequenceRequest + ) + expect(moved.error).toMatch(/send will not start/) + expect(moved.error).toMatch(/check your wallet/i) + expect(moved.error).not.toMatch(/went through/i) + }) + + it('names the token and the follow-on when the balance can’t be read', () => { + const said = swapLine('signing', 'unknown', undefined, withKind({ kind: 'stake' })) + expect(said).toMatch(/USDC/) + expect(said).toMatch(/the stake will not follow/) + }) + + it('labels step 2 by what it does, with the full address on a send', () => { + expect(stepLabels(withKind({ kind: 'supply' }))[1]).toBe('Deposit') + expect(stepLabels(withKind({ kind: 'supply', venue: 'Kamino' }))[1]).toBe('Deposit on Kamino') + expect(stepLabels(withKind({ kind: 'stake' }))[1]).toBe('Stake') + expect(stepLabels(withKind({ kind: 'stake', venue: 'Meta Pool' }))[1]).toBe( + 'Stake on Meta Pool' + ) + expect(stepLabels(withKind({ kind: 'send', recipient: ALICE }))[1]).toBe(`Send to ${ALICE}`) + }) + + it('uses the table’s symbol however the token was typed', () => { + const jito: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '1', tokenIn: 'usdc', tokenOut: 'jitosol' }, + followOn: { kind: 'send', recipient: ALICE }, + } + expect(secondStepLabel(jito, '4.98')).toBe('Send 4.98 JitoSOL') + expect(deliveredLine('4.98', jito)).toBe('About 4.98 JitoSOL reached your wallet.') + expect(stepLabels(jito)[0]).toBe('Swap 1 USDC to JitoSOL') + const unknown: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '1', tokenIn: 'usdc', tokenOut: 'scam' }, + followOn: { kind: 'stake' }, + } + expect(secondStepLabel(unknown, '1')).toBe('Stake 1 SCAM') + }) + + it('offers the button with what arrived, and none for a cash-out', () => { + expect(secondStepLabel(withKind({ kind: 'supply' }), '4.98')).toBe('Deposit 4.98 USDC') + expect(secondStepLabel(withKind({ kind: 'stake' }), '4.98')).toBe('Stake 4.98 USDC') + expect(secondStepLabel(withKind({ kind: 'send', recipient: ALICE }), '4.98')).toBe( + 'Send 4.98 USDC' + ) + expect(secondStepLabel(REQUEST, '4.98')).toBeUndefined() + }) + + it('records the end of step 2 once, and says step 2 did not run only before it was signed', () => { + const send = OTHERS[2] as ChainSequenceRequest + expect(sequenceOutcome('following', '5', undefined, send)).toBeUndefined() + expect(sequenceOutcome('followed', '5', undefined, send)).toBeUndefined() + expect(sequenceOutcome('unsettled', '5', 'Check.', send)).toBe('Check.') + expect(endedNote('following', '5', send)).toBe(strandedReply('USDC', '5', send.followOn)) + expect(endedNote('followed', '5', send)).toBeUndefined() + expect(endedNote('unsettled', '5', send)).toBeUndefined() + }) + + it('marks both steps as step 2 runs and ends', () => { + expect(stepMarks('following', true)).toEqual(['done', 'now']) + expect(stepMarks('followed', true)).toEqual(['done', 'done']) + expect(stepMarks('unsettled', true)).toEqual(['done', 'stopped']) + }) +}) + +describe('secondStepPress', () => { + it('prepares from a settled step 1 for the wallet it was built for', () => { + const request = { kind: 'send', amount: '5', tokenIn: 'USDC', recipient: BOB } as const + expect(secondStepPress(request, ALICE, ALICE)).toEqual({ kind: 'prepare', request }) + }) + + it('refuses another wallet in the existing sentence, and no wallet at all', () => { + const request = { kind: 'stake', amount: '5', tokenIn: 'SUI' } as const + expect(secondStepPress(request, BOB, ALICE)).toEqual({ + kind: 'refused', + message: accountMismatch(BOB, ALICE), + }) + expect(secondStepPress(request, undefined, ALICE)).toEqual({ + kind: 'refused', + message: accountMismatch(undefined, ALICE), + }) + }) + + it('says nothing can be sent when there is no amount to prepare', () => { + const said = secondStepPress(undefined, ALICE, ALICE) + expect(said.kind).toBe('refused') + if (said.kind === 'refused') expect(said.message).toMatch(/too small/) + }) +}) + +describe('readFollowOnRefusal', () => { + const SEND: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '1', tokenIn: 'SOL', tokenOut: 'usdc' }, + followOn: { kind: 'send', recipient: BOB }, + } + + afterEach(() => vi.unstubAllGlobals()) + + function answering(status: number, body: unknown) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })) + vi.stubGlobal('fetch', fetchMock) + return fetchMock + } + + it('asks about what follows the swap, with the swap’s output', async () => { + const fetchMock = answering(200, { ok: true }) + await expect(readFollowOnRefusal('solana', SEND, ALICE)).resolves.toBeUndefined() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('/api/chain/solana/follow-on') + expect(JSON.parse(init.body as string)).toEqual({ + account: ALICE, + step: { kind: 'send', tokenIn: 'usdc', recipient: BOB }, + }) + }) + + it('passes on the sentence for a step that can’t run', async () => { + answering(422, { error: 'Bob is not registered. Nothing was signed or sent.' }) + await expect(readFollowOnRefusal('solana', SEND, ALICE)).resolves.toBe( + 'Bob is not registered. Nothing was signed or sent.' + ) + }) + + it('fails closed on any other answer, so no swap starts unchecked', async () => { + answering(502, { error: 'x' }) + await expect(readFollowOnRefusal('solana', SEND, ALICE)).resolves.toMatch( + /could not check.*Nothing was signed or sent\.$/ + ) + answering(200, { ok: false }) + await expect(readFollowOnRefusal('solana', SEND, ALICE)).resolves.toMatch(/could not check/) + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('offline') + }) + ) + await expect(readFollowOnRefusal('solana', SEND, ALICE)).resolves.toMatch(/could not check/) + }) +}) + +describe('stageOnForm', () => { + const closed = { open: false, locked: false, paid: false } + const open = { open: true, locked: false, paid: false } + + it('goes back to ready when the form is left, so it can be pressed again', () => { + expect(stageOnForm('next', closed)).toBe('ready') + }) + + it('stays while the form is open', () => { + expect(stageOnForm('next', open)).toBe('next') + }) + + it('is finished the moment a cash-out is under way, so the button cannot return', () => { + expect(stageOnForm('next', { ...open, locked: true })).toBe('done') + expect(stageOnForm('next', { ...open, paid: true })).toBe('done') + expect(stageOnForm('next', { ...closed, paid: true })).toBe('done') + }) + + it('touches no other stage', () => { + for (const stage of ['swap', 'measuring', 'ready', 'stranded', 'done'] as const) { + expect(stageOnForm(stage, { ...open, locked: true })).toBe(stage) + } + }) +}) + +describe('stageAfterMeasure', () => { + it('is ready as soon as an amount is measured', () => { + expect(stageAfterMeasure('5', 1, 20)).toBe('ready') + expect(stageAfterMeasure('5', 20, 20)).toBe('ready') + }) + + it('keeps looking before the limit and strands at it', () => { + expect(stageAfterMeasure(undefined, 1, 20)).toBe('keep') + expect(stageAfterMeasure(undefined, 19, 20)).toBe('keep') + expect(stageAfterMeasure(undefined, 20, 20)).toBe('stranded') + }) + + it('polls for the bound the brief sets', () => { + expect(MEASURE_INTERVAL_MS).toBe(3_000) + expect(MEASURE_ATTEMPTS).toBe(20) + }) +}) + +describe('outputToken', () => { + it('finds the id from the table, on each chain a cash-out runs from', () => { + expect(outputToken('solana', 'USDC', 'USDC')?.id).toBe(SOLANA_USDC) + expect(outputToken('sui', 'USDC', 'usdc')?.id).toBe( + '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' + ) + expect(outputToken('avalanche', 'USDC', 'USDC')?.id).toBe( + '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E' + ) + }) + + it('knows nothing it cannot be sure of', () => { + expect(outputToken('solana', undefined, 'USDC')).toBeUndefined() + expect(outputToken('solana', 'USDC', undefined)).toBeUndefined() + expect(outputToken('solana', 'USDT', 'USDC')).toBeUndefined() + expect(outputToken('solana', 'FAKE', 'FAKE')).toBeUndefined() + expect(outputToken('solana', 'usdc', 'usdc')?.id).toBe(SOLANA_USDC) + expect(outputToken('stellar', 'USDC', 'USDC')).toBeUndefined() + }) +}) + +describe('holdingOf', () => { + it('matches on id, never on symbol', () => { + const read = balances({ + tokens: [held('Copycat111', '999', 'USDC'), held(SOLANA_USDC, '1.5')], + }) + expect(holdingOf(read, USDC)).toBe('1.5') + expect(holdingOf(balances({ tokens: [held('Copycat111', '999', 'USDC')] }), USDC)).toBe('0') + }) + + it('reads a token missing from a complete answer as none held', () => { + expect(holdingOf(balances({}), USDC)).toBe('0') + }) + + it('does not read a token missing from an incomplete answer as zero', () => { + expect(holdingOf(balances({ complete: false }), USDC)).toBeUndefined() + expect(holdingOf(balances({ complete: false, unread: ['USDC'] }), USDC)).toBeUndefined() + expect(holdingOf(balances({ unread: ['USDC'] }), USDC)).toBeUndefined() + }) + + it('still reads a token present in an incomplete answer', () => { + const read = balances({ complete: false, knownOnly: true, tokens: [held(SOLANA_USDC, '2')] }) + expect(holdingOf(read, USDC)).toBe('2') + }) +}) + +describe('readHolding', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + const signal = new AbortController().signal + + function answering(status: number, body: unknown): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(body), { status })) + ) + } + + it('asks the chain route for this account, uncached', async () => { + answering(200, balances({ tokens: [held(SOLANA_USDC, '4')] })) + expect(await readHolding('solana', ALICE, USDC, signal)).toBe('4') + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toBe(`/api/chain/solana/balances?account=${ALICE}`) + expect(init.cache).toBe('no-store') + }) + + it('reads nothing from a 503 and from an answer of the wrong shape', async () => { + answering(503, { error: 'balances_unavailable' }) + expect(await readHolding('solana', ALICE, USDC, signal)).toBeUndefined() + answering(200, { tokens: 'none' }) + expect(await readHolding('solana', ALICE, USDC, signal)).toBeUndefined() + }) + + it('reads nothing when the request throws', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('offline') + }) + ) + expect(await readHolding('solana', ALICE, USDC, signal)).toBeUndefined() + }) +}) + +describe('beforeReadCounts', () => { + it('trusts a read that lands while the card is being built or is on offer', () => { + expect(beforeReadCounts('building', undefined)).toBe(true) + expect(beforeReadCounts('review', 'solana')).toBe(true) + expect(beforeReadCounts('review', 'evm')).toBe(true) + }) + + it('does not trust one that lands in the wallet when the wallet is not known', () => { + expect(beforeReadCounts('signing', undefined)).toBe(false) + }) + + it('trusts one that lands in the wallet only where the wallet sends nothing itself', () => { + expect(beforeReadCounts('signing', 'solana')).toBe(true) + expect(beforeReadCounts('signing', 'sui')).toBe(true) + expect(beforeReadCounts('signing', 'evm')).toBe(false) + expect(beforeReadCounts('signing', 'near')).toBe(false) + }) + + it.each(['submitting', 'settled', 'pending', 'failed', 'idle'] as const)( + 'does not trust one that lands at %s', + (phase) => { + expect(beforeReadCounts(phase, 'solana')).toBe(false) + } + ) +}) + +describe('measureDelivered', () => { + const noWait = async (): Promise => {} + + function reads(answers: (string | undefined)[]): { + read: () => Promise + count: () => number + most: () => number + } { + let count = 0 + let open = 0 + let most = 0 + return { + read: async () => { + open += 1 + most = Math.max(most, open) + await Promise.resolve() + open -= 1 + const answer = answers[Math.min(count, answers.length - 1)] + count += 1 + return answer + }, + count: () => count, + most: () => most, + } + } + + function args(read: () => Promise, signal = new AbortController().signal) { + return { before: '1', decimals: 6, read, signal, wait: noWait } + } + + it('measures the rise, not the balance', async () => { + const source = reads(['1', '1', '3.5']) + expect(await measureDelivered(args(source.read))).toEqual({ + stage: 'ready', + delivered: '2.5', + }) + expect(source.count()).toBe(3) + }) + + it('strands with no amount when the balance never rises, after the limit and no further', async () => { + const source = reads(['1']) + expect(await measureDelivered(args(source.read))).toEqual({ stage: 'stranded' }) + expect(source.count()).toBe(MEASURE_ATTEMPTS) + }) + + it('counts a failed read as an attempt and does not end early on one', async () => { + const source = reads([undefined, undefined, '6']) + expect(await measureDelivered(args(source.read))).toEqual({ stage: 'ready', delivered: '5' }) + const failing = reads([undefined]) + expect(await measureDelivered(args(failing.read))).toEqual({ stage: 'stranded' }) + expect(failing.count()).toBe(MEASURE_ATTEMPTS) + }) + + it('never has two reads open at once', async () => { + const source = reads(['1']) + await measureDelivered(args(source.read)) + expect(source.most()).toBe(1) + }) + + it('waits the interval between reads and not after the last', async () => { + const source = reads(['1', '2']) + const wait = vi.fn(async () => {}) + await measureDelivered({ ...args(source.read), wait }) + expect(wait).toHaveBeenCalledTimes(1) + expect(wait.mock.calls[0]).toEqual([MEASURE_INTERVAL_MS, expect.anything()]) + }) + + it('stops reading once aborted, and decides nothing', async () => { + const stop = new AbortController() + const source = reads(['1']) + const wait = vi.fn(async () => { + stop.abort() + }) + const result = await measureDelivered({ ...args(source.read, stop.signal), wait }) + expect(result).toBeUndefined() + expect(source.count()).toBe(1) + }) + + it('ignores an answer that lands after the abort', async () => { + const stop = new AbortController() + const result = await measureDelivered( + args(async () => { + stop.abort() + return '9' + }, stop.signal) + ) + expect(result).toBeUndefined() + }) +}) + +describe('readyAction', () => { + it('opens the form at the measured amount, less the room Weave may take', () => { + expect(readyAction('10.2', false, ALICE, ALICE)).toEqual({ kind: 'open', amount: '10' }) + expect(readyAction('5', false, ALICE, ALICE)).toEqual({ kind: 'open', amount: '4.9' }) + }) + + it('offers nothing for an amount too small to cash out, and says what arrived', () => { + const action = readyAction('0.005', false, ALICE, ALICE) + expect(action.kind).toBe('too-small') + expect(action).toMatchObject({ message: deliveredLine('0.005') }) + expect(deliveredLine('0.005')).toContain('0.005 USDC') + expect(deliveredLine('0.005')).toContain('too small') + }) + + it('says a locked cash-out rather than letting the open return silently', () => { + expect(readyAction('5', true, ALICE, ALICE)).toEqual({ + kind: 'refused', + message: CASHOUT_UNDER_WAY, + }) + }) + + it('refuses another wallet with the existing sentence', () => { + expect(readyAction('5', false, BOB, ALICE)).toEqual({ + kind: 'refused', + message: accountMismatch(BOB, ALICE), + }) + }) + + it('refuses a disconnected wallet too', () => { + expect(readyAction('5', false, undefined, ALICE)).toEqual({ + kind: 'refused', + message: accountMismatch(undefined, ALICE), + }) + }) + + it('reads hex addresses without regard to case', () => { + const lower = '0xabcdef0123456789abcdef0123456789abcdef01' + const upper = '0xABCDEF0123456789ABCDEF0123456789ABCDEF01' + expect(readyAction('5', false, upper, lower).kind).toBe('open') + }) +}) + +describe('what is said', () => { + it('words a step 1 that is not confirmed without claiming it went through', () => { + expect(UNCONFIRMED_SWAP).not.toMatch(/went through/i) + expect(UNCONFIRMED_SWAP).toMatch(/check your wallet/i) + expect(UNCONFIRMED_SWAP).toMatch(/will not start/i) + expect(UNCONFIRMED_SWAP).toMatch(/nothing was paid out/i) + expect(UNCONFIRMED_SWAP).not.toContain('—') + }) + + it('records one sentence per ending, and nothing mid-way', () => { + expect(sequenceOutcome('swap', undefined, undefined)).toBeUndefined() + expect(sequenceOutcome('measuring', undefined, undefined)).toBeUndefined() + expect(sequenceOutcome('next', '5', undefined)).toBeUndefined() + expect(sequenceOutcome('ready', '5', undefined)).toBe(deliveredLine('5')) + expect(sequenceOutcome('stranded', undefined, UNCONFIRMED_SWAP)).toBe(UNCONFIRMED_SWAP) + expect(sequenceOutcome('done', undefined, 'Ended.')).toBe('Ended.') + }) + + it('does not record a refused press in place of what arrived', () => { + expect(sequenceOutcome('ready', '5', accountMismatch(BOB, ALICE))).toBe(deliveredLine('5')) + }) + + it('says step 2 did not run when a new message ends a swap that settled', () => { + expect(endedNote('ready', '5', REQUEST)).toBe(strandedReply('USDC', '5', REQUEST.followOn)) + expect(endedNote('next', '5', REQUEST)).toBe(strandedReply('USDC', '5', REQUEST.followOn)) + expect(endedNote('measuring', undefined, REQUEST)).toBe( + strandedReply('USDC', undefined, REQUEST.followOn) + ) + expect(endedNote('ready', '5', REQUEST)).toContain('Nothing was paid out.') + }) + + it('says nothing more where nothing settled or all is already said', () => { + for (const stage of ['swap', 'stranded', 'done'] as const) { + expect(endedNote(stage, undefined, REQUEST)).toBeUndefined() + } + expect(endedNote(undefined, undefined, null)).toBeUndefined() + }) +}) + +describe('stepLabels', () => { + it('names both steps', () => { + expect(stepLabels(REQUEST)).toEqual(['Swap 0.5 SOL to USDC', 'Cash out to KES']) + }) + + it('reads the same however the token was typed', () => { + const typed: ChainSequenceRequest = { + swap: { kind: 'swap', amount: '2', tokenIn: 'sol', tokenOut: 'usdc' }, + followOn: { kind: 'cashout' }, + } + expect(stepLabels(typed)).toEqual(['Swap 2 SOL to USDC', 'Cash out']) + }) +}) + +describe('stepMarks', () => { + it('shows step 1 in progress before anything is signed', () => { + expect(stepMarks('swap', false)).toEqual(['now', 'waiting']) + }) + + it('moves to step 2 only once it is on offer', () => { + expect(stepMarks('measuring', true)).toEqual(['done', 'waiting']) + expect(stepMarks('ready', true)).toEqual(['done', 'now']) + expect(stepMarks('next', true)).toEqual(['done', 'now']) + }) + + it('does not tick a step 1 nobody can vouch for', () => { + expect(stepMarks('stranded', false)).toEqual(['stopped', 'stopped']) + expect(stepMarks('done', false)).toEqual(['stopped', 'stopped']) + }) + + it('ticks a step 1 that settled, even when step 2 can go no further', () => { + expect(stepMarks('stranded', true)).toEqual(['done', 'stopped']) + }) + + it('does not tick a cash-out that is only under way, since the payout has not landed', () => { + expect(stepMarks('done', true)).toEqual(['done', 'now']) + }) +}) + +describe('cashoutSequence', () => { + it('turns a cash-out of another token into a swap to USDC', () => { + expect( + cashoutSequence({ kind: 'sequence', token: 'SOL', amount: '5', currency: 'KES' }) + ).toEqual({ + swap: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn: { kind: 'cashout', currency: 'KES' }, + }) + }) + + it('leaves the currency open when none was named', () => { + expect(cashoutSequence({ kind: 'sequence', token: 'SOL', amount: '5' }).followOn).toEqual({ + kind: 'cashout', + }) + }) +}) + +describe('currencyStep', () => { + const KES = { code: 'KES', name: 'Kenyan Shilling', symbol: 'KSh', country: 'KE' } + const NGN = { code: 'NGN', name: 'Nigerian Naira', symbol: 'N', country: 'NG' } + + it('refuses before signing when nothing pays out', () => { + expect(currencyStep(undefined, [])).toEqual({ + kind: 'refuse', + message: "Cash-outs aren't available right now.", + }) + expect(currencyStep('KES', []).kind).toBe('refuse') + }) + + it('refuses before signing a currency this chain does not pay', () => { + const step = currencyStep('UGX', [KES, NGN]) + expect(step.kind).toBe('refuse') + expect(step).toMatchObject({ message: expect.stringContaining('UGX') }) + }) + + it('runs a named currency that is offered', () => { + expect(currencyStep('KES', [KES, NGN])).toEqual({ kind: 'run', currency: 'KES' }) + }) + + it('runs the only currency there is, and asks otherwise', () => { + expect(currencyStep(undefined, [KES])).toEqual({ kind: 'run', currency: 'KES' }) + expect(currencyStep(undefined, [KES, NGN])).toEqual({ kind: 'ask', choices: [KES, NGN] }) + }) +}) + +describe('sequenceWordsMatch', () => { + const asked = { amount: '5', token: 'SOL' } + + it('finds the amount and the token in what the person typed', () => { + expect(sequenceWordsMatch(asked, ['please cash out 5 SOL'])).toBe(true) + expect(sequenceWordsMatch(asked, ['get my sol to my bank', '5'])).toBe(true) + expect(sequenceWordsMatch(asked, ['5sol to my bank'])).toBe(true) + expect(sequenceWordsMatch(asked, ['sell SOL, 5 of it.'])).toBe(true) + }) + + it('counts 5 USDC as 5 of the token only when the token is USDC', () => { + expect(sequenceWordsMatch({ amount: '5', token: 'USDC' }, ['send 5 USDC to bob.near'])).toBe( + true + ) + expect(sequenceWordsMatch({ amount: '5', token: 'USDC' }, ['send 5 USDT to bob.near'])).toBe( + false + ) + expect(sequenceWordsMatch(asked, ['cash out SOL, I want 5 USDC'])).toBe(false) + }) + + it('compares numbers, not text', () => { + expect(sequenceWordsMatch(asked, ['cash out 5.0 SOL'])).toBe(true) + expect(sequenceWordsMatch({ amount: '5.0', token: 'SOL' }, ['cash out 5 SOL'])).toBe(true) + expect(sequenceWordsMatch({ amount: '0.5', token: 'SOL' }, ['cash out .5 SOL'])).toBe(true) + expect(sequenceWordsMatch({ amount: '1000', token: 'SOL' }, ['cash 1,000 sol'])).toBe(true) + }) + + it('refuses a different amount', () => { + expect(sequenceWordsMatch({ amount: '12', token: 'SOL' }, ['get my SOL to my bank'])).toBe( + false + ) + expect(sequenceWordsMatch(asked, ['cash out 6 SOL'])).toBe(false) + }) + + it.each(['cash out 15 SOL', 'cash out 0.5 SOL', 'cash out 5.5 SOL', 'cash out 55 SOL'])( + 'does not find 5 inside another number: %s', + (said) => { + expect(sequenceWordsMatch(asked, [said])).toBe(false) + } + ) + + it.each([ + 'cash out $5 of SOL', + 'cash out $ 5 of SOL', + 'cash out ₦5 of SOL', + 'cash out 5 dollars of SOL', + 'cash out 5 usd of my sol', + '5k SOL', + '5% of my SOL', + ])('does not take a figure of something else for an amount of the token: %s', (said) => { + expect(sequenceWordsMatch(asked, [said])).toBe(false) + }) + + it('reads a chain name as its token, both ways round', () => { + expect(sequenceWordsMatch(asked, ['cash out 5 solana'])).toBe(true) + expect(sequenceWordsMatch(asked, ['cash out 5solana'])).toBe(true) + expect(sequenceWordsMatch(asked, ['get my Solana to my bank', '5'])).toBe(true) + expect(sequenceWordsMatch({ amount: '5', token: 'SOLANA' }, ['cash out 5 sol'])).toBe(true) + expect(sequenceWordsMatch({ amount: '5', token: 'SOLANA' }, ['cash out 5 solana'])).toBe(true) + expect(sequenceWordsMatch({ amount: '5', token: 'AVAX' }, ['cash out 5 avalanche'])).toBe(true) + expect(sequenceWordsMatch({ amount: '5', token: 'MON' }, ['cash out 5 monad'])).toBe(true) + }) + + it('does not read one chain name as another token, or a longer word as a chain name', () => { + expect(sequenceWordsMatch({ amount: '5', token: 'AVAX' }, ['cash out 5 solana'])).toBe(false) + expect(sequenceWordsMatch(asked, ['cash out 5 solanax'])).toBe(false) + expect(sequenceWordsMatch(asked, ['cash out 5 SOLfoo'])).toBe(false) + }) + + it.each([ + 'cash out 5 USDT on solana', + 'cash out 5 USDT on my Solana', + 'cash out 5 USDT from the solana chain', + 'cash out 5 USDT in solana', + 'send 5 USDT from my solana wallet to my bank', + 'cash out 5 USDT, my solana account', + ])('does not take a chain named as a place for the token: %s', (said) => { + expect(sequenceWordsMatch(asked, [said])).toBe(false) + }) + + it('still reads the symbol itself after such a word', () => { + expect(sequenceWordsMatch(asked, ['cash out 5 sol on solana'])).toBe(true) + }) + + it('refuses a token only the model named, since only the person is passed in', () => { + expect(sequenceWordsMatch(asked, ['put 5 into my bank'])).toBe(false) + expect(sequenceWordsMatch(asked, [])).toBe(false) + }) + + it('escapes a token, so its symbols are read as themselves', () => { + expect(sequenceWordsMatch({ amount: '5', token: 'USD+' }, ['cash out 5 USD+'])).toBe(true) + expect(sequenceWordsMatch({ amount: '5', token: 'USD+' }, ['cash out 5 USDD'])).toBe(false) + }) +}) + +describe('readBeforeSwap', () => { + const noWait = async (): Promise => {} + + it('reads once when the first answers', async () => { + const read = vi.fn(async () => '3') + expect(await readBeforeSwap(read, () => true, noWait)).toBe('3') + expect(read).toHaveBeenCalledTimes(1) + }) + + it('tries once more after the pause while the card is unsigned', async () => { + const read = vi.fn<() => Promise>() + read.mockResolvedValueOnce(undefined).mockResolvedValueOnce('3') + const wait = vi.fn(noWait) + expect(await readBeforeSwap(read, () => true, wait)).toBe('3') + expect(read).toHaveBeenCalledTimes(2) + expect(wait).toHaveBeenCalledWith(BEFORE_RETRY_MS) + expect(BEFORE_RETRY_MS).toBe(1_500) + }) + + it('tries only once more', async () => { + const read = vi.fn(async () => undefined) + expect(await readBeforeSwap(read, () => true, noWait)).toBeUndefined() + expect(read).toHaveBeenCalledTimes(2) + }) + + it('does not retry once the card has moved on', async () => { + const read = vi.fn(async () => undefined) + expect(await readBeforeSwap(read, () => false, noWait)).toBeUndefined() + expect(read).toHaveBeenCalledTimes(1) + }) + + it('does not retry if the card moves on during the pause', async () => { + const read = vi.fn(async () => undefined) + let open = true + const wait = async (): Promise => { + open = false + } + expect(await readBeforeSwap(read, () => open, wait)).toBeUndefined() + expect(read).toHaveBeenCalledTimes(1) + }) +}) + +describe('keepsBefore', () => { + const token = { symbol: 'USDC', id: SOLANA_USDC, decimals: 6 } + + it('keeps a read under way or known for this wallet and token, so a decline reads nothing again', () => { + expect(keepsBefore({ status: 'reading', account: ALICE, token }, ALICE, SOLANA_USDC)).toBe(true) + expect( + keepsBefore({ status: 'known', amount: '1', account: ALICE, token }, ALICE, SOLANA_USDC) + ).toBe(true) + }) + + it('reads again for another wallet, another token, or after a read that failed', () => { + expect(keepsBefore({ status: 'reading', account: ALICE, token }, BOB, SOLANA_USDC)).toBe(false) + expect(keepsBefore({ status: 'reading', account: ALICE, token }, ALICE, 'other')).toBe(false) + expect(keepsBefore({ status: 'unknown' }, ALICE, SOLANA_USDC)).toBe(false) + }) +}) + +describe('swapLine', () => { + it('says step 2 follows while nothing says otherwise', () => { + expect(swapLine('idle', 'unknown', undefined)).toBe(SWAP_NEXT_STEP) + expect(swapLine('building', 'reading', undefined)).toBe(SWAP_NEXT_STEP) + expect(swapLine('building', 'unknown', undefined)).toBe(SWAP_NEXT_STEP) + expect(swapLine('review', 'reading', undefined)).toBe(SWAP_NEXT_STEP) + expect(swapLine('review', 'known', undefined)).toBe(SWAP_NEXT_STEP) + }) + + it('does not promise step 2 on a card whose balance could not be read', () => { + expect(swapLine('review', 'unknown', undefined)).toBe(BALANCE_UNREAD) + expect(swapLine('signing', 'unknown', undefined)).toBe(BALANCE_UNREAD) + expect(BALANCE_UNREAD).toBe( + 'I could not read your USDC balance, so if you sign this swap the cash-out will not follow. You would hold the USDC and can cash it out yourself.' + ) + }) + + it('shows a refused size in its place', () => { + expect(swapLine('review', 'known', 'Too small.')).toBe('Too small.') + expect(swapLine('review', 'reading', 'Too small.')).toBe('Too small.') + }) + + it('says the unread balance first, since no size matters without it', () => { + expect(swapLine('review', 'unknown', 'Too small.')).toBe(BALANCE_UNREAD) + }) +}) + +describe('quoteStep', () => { + it('asks about what the button would open, not the whole receive', () => { + expect(quoteStep('10.2')).toEqual({ kind: 'ask', amount: '10' }) + }) + + it('needs no answer for an amount that rounds to nothing or cannot be read', () => { + expect(quoteStep('0.005')).toEqual({ kind: 'note', note: TOO_SMALL_FOR_WEAVE }) + expect(quoteStep('about five')).toEqual({ kind: 'note', note: TOO_SMALL_FOR_WEAVE }) + }) + + it('says nothing with no amount on the card', () => { + expect(quoteStep(undefined)).toEqual({ kind: 'none' }) + }) +}) + +describe('quoteNote', () => { + it('says nothing for a quote Weave would take', () => { + expect(quoteNote(200, { costPercent: 1 })).toBeUndefined() + expect(quoteNote(200, { costPercent: 5 })).toBeUndefined() + }) + + it('says so past the cost the order route refuses at', () => { + expect(quoteNote(200, { costPercent: 5.01 })).toBe(TOO_SMALL_FOR_WEAVE) + expect(TOO_SMALL_FOR_WEAVE).toBe( + 'Weave would refuse a cash-out this small, so this swap would leave you holding the USDC.' + ) + }) + + it('uses a refusal in the route own plain words, and what it means here', () => { + const message = 'No price is available for this cash-out right now. No money has moved.' + expect(quoteNote(422, { error: 'rejected', message })).toBe( + `${message} This swap would leave you holding the USDC.` + ) + }) + + it('falls back when a refusal has no plain words', () => { + expect(quoteNote(422, { error: 'rejected' })).toBe(TOO_SMALL_FOR_WEAVE) + expect(quoteNote(422, { message: 'E_MIN' })).toBe(TOO_SMALL_FOR_WEAVE) + expect(quoteNote(422, { message: 'not a sentence' })).toBe(TOO_SMALL_FOR_WEAVE) + }) + + it('says so for an amount the route cannot read', () => { + expect(quoteNote(400, { error: 'invalid_amount', message: 'Enter an amount.' })).toBe( + TOO_SMALL_FOR_WEAVE + ) + }) + + it.each([ + [503, { error: 'unavailable', message: 'Cash-outs are busy right now.' }], + [429, { error: 'rate_limited' }], + [401, { error: 'unauthenticated', message: 'Connect your wallet to cash out.' }], + [400, { error: 'unknown_currency', message: 'This chain does not cash out to that currency.' }], + [200, {}], + ])('adds nothing for %i %j, which says nothing about the size', (status, body) => { + expect(quoteNote(status, body)).toBeUndefined() + }) +}) + +describe('readCashoutNote', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('asks the quote route for this chain, currency and amount, and reads the refusal', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: 'rejected' }), { status: 422 })) + ) + expect(await readCashoutNote('solana', 'KES', '1.4')).toBe(TOO_SMALL_FOR_WEAVE) + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toBe('/api/cashout/quote?chain=solana¤cy=KES&amount=1.4') + }) + + it('adds nothing when the read fails or is not JSON', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('offline') + }) + ) + expect(await readCashoutNote('solana', 'KES', '1.4')).toBeUndefined() + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 200 })) + ) + expect(await readCashoutNote('solana', 'KES', '1.4')).toBeUndefined() + }) +}) + +describe('questionAnswerFrom', () => { + const sequence = { request: REQUEST, chain: 'solana' } + const open = { sequence } + + it('reads "cash out to KES" as the answer to the swap waiting on it', () => { + expect(questionAnswerFrom(open, { kind: 'open', currency: 'KES' })).toBe('KES') + }) + + it('does not take a request with its own amount for an answer', () => { + expect(questionAnswerFrom(open, { kind: 'open', amount: '5', currency: 'KES' })).toBeUndefined() + }) + + it('leaves every other route alone', () => { + expect(questionAnswerFrom(open, { kind: 'pass' })).toBeUndefined() + expect(questionAnswerFrom(open, { kind: 'ask' })).toBeUndefined() + expect(questionAnswerFrom(open, { kind: 'open' })).toBeUndefined() + expect(questionAnswerFrom(open, { kind: 'currency', currency: 'USD' })).toBeUndefined() + }) + + it('leaves a plain question, an answered one and no question alone', () => { + const route = { kind: 'open', currency: 'KES' } as const + expect(questionAnswerFrom({}, route)).toBeUndefined() + expect(questionAnswerFrom({ sequence, answer: 'Kenya' }, route)).toBeUndefined() + expect(questionAnswerFrom(null, route)).toBeUndefined() + }) +}) + +describe('questionChainRefusal', () => { + it('lets an answer through on the chain it was asked on', () => { + expect(questionChainRefusal('avalanche', 'avalanche')).toBeUndefined() + }) + + it('refuses one from another chain, with nothing signed', () => { + const refusal = questionChainRefusal('avalanche', 'monad') + expect(refusal).toContain('another chain') + expect(refusal).toContain('Nothing was signed or sent.') + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts new file mode 100644 index 00000000..abfbe460 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -0,0 +1,733 @@ +import { describe, expect, it } from 'vitest' + +import { assertNativeReserve } from '../chains/prepare' +import { chainHelp } from '../chains/explain' +import { chainCapabilities } from '../chains/capabilities' +import { + chainRouteFromReading, + parseChainSequence, + type ReadSwap, + sequenceFromReading, + swapFromReading, +} from '../chains/parse-sequence' +import { + SEQUENCE_CASHOUT_CHAINS, + cashoutAmountFrom, + deliveredAmount, + followOnRefusal, + strandedReply, +} from '../chains/sequence' +import { ChainActionRefused, type ChainModule, type WalletBalances } from '../chains/types' +import { unsupportedTokenReply } from '../weave/cashout-view' +import { parseCashout, routeCashout } from '../weave/parse-cashout' + +const ADDRESS = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' +const SOL_MINT = 'So11111111111111111111111111111111111111112' + +describe('parseChainSequence', () => { + it('reads a chain name as its token, in or out', () => { + expect(parseChainSequence('swap 1 solana to USDC then cash out')).toMatchObject({ + swap: { tokenIn: 'SOL', tokenOut: 'USDC' }, + }) + expect(parseChainSequence('swap 5 USDC to monad then deposit')).toMatchObject({ + swap: { tokenIn: 'USDC', tokenOut: 'MON' }, + }) + }) + + it('names the token, not the chain, when refusing a cash-out that does not end in USDC', () => { + expect(parseChainSequence('swap 5 USDC to solana then cash out')).toEqual({ + refusal: + 'Cash-outs take USDC, so this swap would have to end in USDC, not SOL. Nothing was signed or sent.', + }) + }) + + it.each([ + ['swap 5 SOL to USDC then cash out', { kind: 'cashout' }], + ['swap 5 SOL to USDC and cash out to kes', { kind: 'cashout', currency: 'KES' }], + ['swap 5 SOL to USDC, offramp', { kind: 'cashout' }], + ['swap 5 SOL to USDC and then withdraw to bank', { kind: 'cashout' }], + ['swap 5 SOL to USDC then deposit', { kind: 'supply' }], + ['swap 5 SOL to USDC then stake', { kind: 'stake' }], + [`swap 5 SOL to USDC then send to ${ADDRESS}`, { kind: 'send', recipient: ADDRESS }], + [`swap 5 SOL to USDC then send it to ${ADDRESS}`, { kind: 'send', recipient: ADDRESS }], + ])('reads %s', (text, followOn) => { + expect(parseChainSequence(text)).toEqual({ + swap: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined }, + followOn, + }) + }) + + it('keeps the recipient in the case it was written', () => { + const read = parseChainSequence('swap 5 SOL to USDC then transfer to AbCdEf') + expect(read).toMatchObject({ followOn: { kind: 'send', recipient: 'AbCdEf' } }) + }) + + it('refuses a cash-out after a swap that does not end in USDC', () => { + const read = parseChainSequence('swap 5 SOL to BONK then cash out') + expect(read).toHaveProperty('refusal') + expect((read as { refusal: string }).refusal).toMatch(/take USDC/) + expect((read as { refusal: string }).refusal).toMatch(/Nothing was signed or sent\.$/) + }) + + it('lets any output token go on to supply, stake or send', () => { + expect(parseChainSequence('swap 5 SOL to BONK then stake')).toMatchObject({ + followOn: { kind: 'stake' }, + }) + }) + + it('leaves a plain swap and a plain deposit alone', () => { + expect(parseChainSequence('swap 5 SOL to USDC')).toBeNull() + expect(parseChainSequence('deposit 5 SOL')).toBeNull() + }) +}) + +describe('a token read beside a currency we do not pay', () => { + it('names the currency instead of dropping it', () => { + for (const text of ['cash out 5 sol to brl', 'sell 5 sol for euros']) { + const read = parseCashout(text) + expect(read).toHaveProperty('unsupportedCurrency') + expect(routeCashout(read, 'solana')).toMatchObject({ kind: 'currency' }) + expect(routeCashout(read, 'stellar')).toEqual({ kind: 'pass' }) + } + }) +}) + +describe('parseChainSequence strictness', () => { + it('reads no amount from a decimal comma, and keeps real groups', () => { + for (const amount of ['1,5', '0,5', '1,50', '10,00', '0,500', '00,500', '01,000']) { + expect(parseChainSequence(`swap ${amount} SOL to USDC then cash out`)).toBeNull() + } + const read = (typed: string): unknown => { + const parsed = parseChainSequence(`swap ${typed} SOL to USDC then cash out`) + return parsed !== null && 'swap' in parsed ? parsed.swap.amount : undefined + } + expect(read('1,000.5')).toBe('1000.5') + expect(read('.5')).toBe('0.5') + expect(read('05')).toBe('5') + }) + + it.each([ + 'swap 5 SOL to USDC then send 2 to X', + 'swap 5 SOL to USDC and swap 2 to BONK', + 'swap 5 SOL to USDC then cash out half', + 'swap 5 SOL to USDC then cash out 3', + `swap 5 SOL to USDC then send to ${ADDRESS}?`, + `swap 5 SOL to USDC then send to ${ADDRESS},`, + `swap 5 SOL to USDC then send to ${ADDRESS})`, + ])('returns null for %s', (text) => { + expect(parseChainSequence(text)).toBeNull() + }) + + it('reads a NEAR account and drops one trailing stop', () => { + expect(parseChainSequence('swap 5 SOL to USDC then send to alice.near.')).toMatchObject({ + followOn: { kind: 'send', recipient: 'alice.near' }, + }) + expect(parseChainSequence('swap 5 SOL to USDC then send to a_b-c.near!')).toMatchObject({ + followOn: { kind: 'send', recipient: 'a_b-c.near' }, + }) + }) +}) + +describe('routeCashout with an amount', () => { + it('starts no swap on an amount with a leading zero, which may be a decimal comma', () => { + for (const typed of ['cash out 0,500 sol', 'cash out 05 sol', 'cash out 0,500 sol to kes']) { + expect(routeCashout(parseCashout(typed), 'solana')).toEqual({ kind: 'token', token: 'SOL' }) + } + expect(routeCashout(parseCashout('cash out 0,500 sol'), 'avalanche')).toMatchObject({ + kind: 'token', + }) + }) + + it('still starts one on an ordinary amount', () => { + expect(routeCashout(parseCashout('cash out 0.5 sol'), 'solana')).toMatchObject({ + kind: 'sequence', + amount: '0.5', + }) + expect(routeCashout(parseCashout('cash out 1,500 sol'), 'solana')).toMatchObject({ + kind: 'sequence', + amount: '1500', + }) + }) + + it('leaves Stellar and what the parser reads exactly as they were', () => { + expect(parseCashout('cash out 0,500 sol')).toEqual({ unsupportedToken: 'SOL', amount: '0500' }) + expect(routeCashout(parseCashout('cash out 0,500 sol'), 'stellar')).toEqual({ kind: 'pass' }) + expect(routeCashout(parseCashout('cash out 0,500 sol to kes'), 'stellar')).toEqual({ + kind: 'token', + token: 'SOL', + }) + }) + + it('becomes a sequence on chains that can finish it', () => { + expect(routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'solana')).toEqual({ + kind: 'sequence', + token: 'SOL', + amount: '0.5', + }) + expect( + routeCashout({ unsupportedToken: 'SOL', amount: '0.5', currency: 'KES' }, 'sui') + ).toEqual({ kind: 'sequence', token: 'SOL', amount: '0.5', currency: 'KES' }) + }) + + it('reads USDT as a sequence too', () => { + expect(routeCashout(parseCashout('cash out 5 USDT to KES'), 'avalanche')).toEqual({ + kind: 'sequence', + token: 'USDT', + amount: '5', + currency: 'KES', + }) + }) + + it('passes on stellar and stays a token case elsewhere', () => { + expect(routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'stellar')).toEqual({ + kind: 'pass', + }) + expect(routeCashout({ unsupportedToken: 'SOL' }, 'solana')).toEqual({ + kind: 'token', + token: 'SOL', + }) + expect(routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'near')).toEqual({ + kind: 'token', + token: 'SOL', + }) + expect(routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'monad')).toEqual({ + kind: 'pass', + }) + }) + + it('keeps the amount read, but not a dollar figure as a token amount', () => { + expect(parseCashout('cash out 5 sol')).toEqual({ unsupportedToken: 'SOL', amount: '5' }) + expect(parseCashout('cash out $5 sol')).toEqual({ unsupportedToken: 'SOL', dollars: '5' }) + }) + + it('says on NEAR only what is true there', () => { + const reply = unsupportedTokenReply('SOL', 'near') + expect(reply).toMatch(/NEAR/) + expect(reply).not.toMatch(/then cash out/i) + expect(unsupportedTokenReply('SOL', 'solana')).toMatch(/Swap your SOL to USDC first/) + }) +}) + +describe('deliveredAmount', () => { + it('measures in base units', () => { + expect(deliveredAmount('1.5', '6.5', 6)).toBe('5') + expect(deliveredAmount('0.1', '0.3', 6)).toBe('0.2') + }) + + it('measures at 18 and 24 decimals, and ignores trailing zeros', () => { + expect(deliveredAmount('1', '1.000000000000000001', 18)).toBe('0.000000000000000001') + expect(deliveredAmount('0.1', '0.3', 18)).toBe('0.2') + expect(deliveredAmount('1', '2.5', 24)).toBe('1.5') + expect(deliveredAmount('1.5', '6.50', 6)).toBe('5') + }) + + it('gives nothing when a side is missing or nothing arrived', () => { + expect(deliveredAmount(undefined, '6.5', 6)).toBeUndefined() + expect(deliveredAmount('1', undefined, 6)).toBeUndefined() + expect(deliveredAmount('2', '2', 6)).toBeUndefined() + expect(deliveredAmount('3', '2', 6)).toBeUndefined() + }) +}) + +describe('cashoutAmountFrom', () => { + it('leaves room for the fee, rounding down to 2 places', () => { + expect(cashoutAmountFrom('5')).toBe('4.9') + expect(cashoutAmountFrom('5.05')).toBe('4.95') + expect(cashoutAmountFrom('0.02')).toBe('0.01') + expect(cashoutAmountFrom('1.234567')).toBe('1.21') + }) + + it('gives nothing for zero or what is not an amount', () => { + expect(cashoutAmountFrom('0.01')).toBeUndefined() + expect(cashoutAmountFrom('abc')).toBeUndefined() + expect(cashoutAmountFrom('0')).toBeUndefined() + }) +}) + +describe('followOnRefusal', () => { + const swapVenue = [{ kinds: ['swap'] as const }] + const earnVenue = [{ kinds: ['swap', 'supply', 'stake'] as const }] + + it('names why a cash-out cannot run', () => { + expect(followOnRefusal('near', 'NEAR', { kind: 'cashout' }, swapVenue)).toMatch( + /Weave.*NEAR deposits.*unavailable/ + ) + expect(followOnRefusal('monad', 'Monad', { kind: 'cashout' }, swapVenue)).toMatch( + /don't run on Monad/ + ) + expect(followOnRefusal('solana', 'Solana', { kind: 'cashout' }, swapVenue)).toBeUndefined() + }) + + it('names the chain when no venue offers a supply or stake', () => { + expect(followOnRefusal('sui', 'Sui', { kind: 'supply' }, swapVenue)).toMatch(/Sui/) + expect(followOnRefusal('sui', 'Sui', { kind: 'stake' }, swapVenue)).toMatch(/stake/) + expect(followOnRefusal('sui', 'Sui', { kind: 'supply' }, earnVenue)).toBeUndefined() + }) + + it('lets a send through', () => { + expect( + followOnRefusal('solana', 'Solana', { kind: 'send', recipient: ADDRESS }, earnVenue) + ).toBeUndefined() + }) + + it('ends every refusal the same way', () => { + for (const r of [ + followOnRefusal('near', 'NEAR', { kind: 'cashout' }, []), + followOnRefusal('monad', 'Monad', { kind: 'cashout' }, []), + followOnRefusal('sui', 'Sui', { kind: 'supply' }, []), + ]) { + expect(r).toMatch(/ Nothing was signed or sent\.$/) + } + }) + + it('lists the chains that can cash out', () => { + expect([...SEQUENCE_CASHOUT_CHAINS]).toEqual(['solana', 'sui', 'avalanche']) + }) +}) + +describe('strandedReply', () => { + it('says what reached the wallet and that the next step did not run', () => { + const reply = strandedReply('USDC', '5', { kind: 'supply' }) + expect(reply).toBe( + 'The swap went through and about 5 USDC reached your wallet, but the next step did not run.' + ) + }) + + it('names no number when the amount is unknown', () => { + const reply = strandedReply('USDC', undefined, { kind: 'stake' }) + expect(reply).not.toMatch(/\d/) + expect(reply).toBe( + 'The swap went through, but I could not confirm what reached your wallet, so the next step did not run. What arrived stays in your wallet. Check your wallet before doing anything else.' + ) + }) + + it('adds that nothing was paid out only for a cash-out', () => { + expect(strandedReply('USDC', '5', { kind: 'cashout' })).toMatch(/ Nothing was paid out\.$/) + expect(strandedReply('USDC', undefined, { kind: 'cashout' })).toMatch( + / Nothing was paid out\.$/ + ) + expect(strandedReply('USDC', '5', { kind: 'send', recipient: 'x' })).not.toMatch(/paid out/) + }) +}) + +describe('assertNativeReserve', () => { + function chainWith(balances: () => Promise, calls = { n: 0 }): ChainModule { + return { + chain: 'solana', + tokens: [ + { symbol: 'SOL', id: SOL_MINT, decimals: 9 }, + { symbol: 'USDC', id: 'usdc-mint', decimals: 6 }, + ], + balances: async () => { + calls.n += 1 + return balances() + }, + } as unknown as ChainModule + } + const held = + (amount: string): (() => Promise) => + async () => ({ + complete: true, + tokens: [{ symbol: 'SOL', id: SOL_MINT, amount, decimals: 9, native: true, listed: true }], + }) + const ask = (amount: string, tokenIn = 'SOL') => ({ + kind: 'swap' as const, + amount, + tokenIn, + tokenOut: 'USDC', + }) + + it('refuses spending the whole native balance, naming what can be spent', async () => { + const run = assertNativeReserve(chainWith(held('0.01477')), ask('0.01477'), 'acct') + await expect(run).rejects.toBeInstanceOf(ChainActionRefused) + await expect(run).rejects.toThrow( + 'Keep about 0.005 SOL for network fees. You can spend up to 0.00977 SOL here. Nothing was signed or sent.' + ) + }) + + it('lets an amount that leaves the reserve through', async () => { + await expect( + assertNativeReserve(chainWith(held('0.01477')), ask('0.009'), 'acct') + ).resolves.toBeUndefined() + }) + + it('does not read anything for a token that is not native', async () => { + const calls = { n: 0 } + await assertNativeReserve(chainWith(held('0.01'), calls), ask('5', 'USDC'), 'acct') + expect(calls.n).toBe(0) + }) + + it('passes when the balance cannot be read', async () => { + const failing = async (): Promise => { + throw new Error('rpc down') + } + await expect( + assertNativeReserve(chainWith(failing), ask('0.01477'), 'acct') + ).resolves.toBeUndefined() + }) + + it('passes when the read never answers, after a short wait', async () => { + const hangs = (): Promise => new Promise(() => undefined) + const started = Date.now() + await expect( + assertNativeReserve(chainWith(hangs), ask('0.01477'), 'acct') + ).resolves.toBeUndefined() + expect(Date.now() - started).toBeLessThan(5000) + }, 8000) + + it('makes no balance-based refusal for wNEAR', async () => { + const calls = { n: 0 } + const near = { + chain: 'near', + tokens: [ + { symbol: 'NEAR', id: 'wrap.near', decimals: 24 }, + { symbol: 'wNEAR', id: 'wrap.near', decimals: 24 }, + ], + balances: async () => { + calls.n += 1 + return { complete: true, tokens: [] } + }, + } as unknown as ChainModule + await expect( + assertNativeReserve( + near, + { kind: 'swap', amount: '99', tokenIn: 'wNEAR', tokenOut: 'USDC' }, + 'a' + ) + ).resolves.toBeUndefined() + expect(calls.n).toBe(0) + }) + + it('matches NEAR, held natively, to the wrapped token the table names', async () => { + const near = { + chain: 'near', + tokens: [{ symbol: 'NEAR', id: 'wrap.near', decimals: 24 }], + balances: async () => ({ + complete: true, + tokens: [ + { symbol: 'NEAR', id: 'near', amount: '1', decimals: 24, native: true, listed: true }, + { + symbol: 'wNEAR', + id: 'wrap.near', + amount: '9', + decimals: 24, + native: false, + listed: true, + }, + ], + }), + } as unknown as ChainModule + await expect( + assertNativeReserve( + near, + { kind: 'swap', amount: '0.99', tokenIn: 'NEAR', tokenOut: 'USDC' }, + 'a' + ) + ).rejects.toThrow(/Keep about 0.05 NEAR/) + }) +}) + +const RECIPIENT = ADDRESS +const reading = (over: Partial = {}) => ({ + text: 'swap 5 SOL to USDC then send it', + tokenIn: 'SOL', + tokenOut: 'USDC', + amountUsd: 5, + amountStated: true, + followOn: null as ReadSwap['followOn'], + ...over, +}) + +describe('sequenceFromReading', () => { + const said = [`swap 5 SOL to USDC then send to ${RECIPIENT}`] + + it('reads a swap with a cash-out, a deposit and a send, and nothing without a follow-on', () => { + const swap = { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC', venue: undefined } + expect( + sequenceFromReading(reading({ followOn: { kind: 'offramp', venue: 'weave' } }), said) + ).toEqual({ swap, followOn: { kind: 'cashout' } }) + expect( + sequenceFromReading(reading({ followOn: { kind: 'lend', venue: 'blend' } }), said) + ).toEqual({ swap, followOn: { kind: 'supply' } }) + expect( + sequenceFromReading( + reading({ followOn: { kind: 'send', venue: '', recipient: RECIPIENT } }), + said + ) + ).toEqual({ swap, followOn: { kind: 'send', recipient: RECIPIENT } }) + expect(sequenceFromReading(reading(), said)).toBeNull() + }) + + it('refuses a send to a recipient the person never typed, character for character', () => { + const other = `${ADDRESS.slice(0, -1)}V` + const out = sequenceFromReading( + reading({ followOn: { kind: 'send', venue: '', recipient: other } }), + said + ) + expect(out).toMatchObject({ refusal: expect.stringContaining('Nothing was signed or sent.') }) + expect(out).toHaveProperty('refusal') + const lower = sequenceFromReading( + reading({ followOn: { kind: 'send', venue: '', recipient: RECIPIENT.toLowerCase() } }), + said + ) + expect(lower).toHaveProperty('refusal') + const inside = sequenceFromReading( + reading({ followOn: { kind: 'send', venue: '', recipient: RECIPIENT.slice(1) } }), + said + ) + expect(inside).toHaveProperty('refusal') + }) + + it('refuses an amount the person never typed', () => { + const out = sequenceFromReading( + reading({ amountUsd: 500, followOn: { kind: 'offramp', venue: 'weave' } }), + said + ) + expect(out).toHaveProperty('refusal') + }) + + it('refuses a token the person never typed', () => { + const out = sequenceFromReading( + reading({ tokenIn: 'JUP', followOn: { kind: 'offramp', venue: 'weave' } }), + said + ) + expect(out).toHaveProperty('refusal') + }) + + it('refuses dollars not typed as dollars, a received amount and a cash-out not ending in USDC', () => { + const f = { followOn: { kind: 'offramp', venue: 'weave' } } + expect(sequenceFromReading(reading({ ...f, amountIsUsd: true }), said)).toHaveProperty( + 'refusal' + ) + expect(sequenceFromReading(reading({ ...f, amountOf: 'received' }), said)).toHaveProperty( + 'refusal' + ) + expect(sequenceFromReading(reading({ ...f, tokenOut: 'USDT' }), said)).toHaveProperty('refusal') + }) + + it('keeps a venue only when the person named it, and refuses one they did not', () => { + const named = reading({ followOn: { kind: 'lend', venue: 'kamino', venueNamed: true } }) + expect(sequenceFromReading(named, ['swap 5 SOL to USDC then deposit on Kamino'])).toMatchObject( + { followOn: { kind: 'supply', venue: 'kamino' } } + ) + expect(sequenceFromReading(named, said)).toHaveProperty('refusal') + }) +}) + +describe('swapFromReading', () => { + it('reads a plain swap from the persons own words only', () => { + expect(swapFromReading(reading(), ['swap 5 SOL to USDC'])).toEqual({ + kind: 'swap', + amount: '5', + tokenIn: 'SOL', + tokenOut: 'USDC', + venue: undefined, + }) + expect(swapFromReading(reading({ amountUsd: 9 }), ['swap 5 SOL to USDC'])).toHaveProperty( + 'refusal' + ) + }) +}) + +describe('chainHelp in the new forms', () => { + it('lists swap then cash out, deposit and send per chain', () => { + const help = (chain: 'solana' | 'near' | 'monad' | 'sui' | 'avalanche', name: string): string => + chainHelp(name, chainCapabilities(chain) ?? []) + expect(help('solana', 'Solana')).toContain('"swap 5 SOL to USDC then cash out"') + expect(help('solana', 'Solana')).toContain('"swap 5 SOL to USDC then send to an address"') + expect(help('sui', 'Sui')).toContain('"swap 5 SUI to USDC then cash out"') + expect(help('avalanche', 'Avalanche')).toContain('"swap 5 AVAX to USDC then cash out"') + expect(help('near', 'NEAR')).toContain('"swap 5 NEAR to USDC then deposit"') + expect(help('near', 'NEAR')).not.toContain('then cash out') + expect(help('near', 'NEAR')).not.toContain('then stake') + expect(help('monad', 'Monad')).toContain('"swap 5 MON to USDC then send to an address"') + expect(help('monad', 'Monad')).not.toMatch(/then (cash out|deposit)/) + expect(help('monad', 'Monad')).toContain('"send 5 USDC to an address"') + }) +}) + +describe('routeCashout plain form on a chain', () => { + it('drops a leading-zero amount off Stellar so the person types it', () => { + expect(routeCashout(parseCashout('cash out 0,500 usdc to kes'), 'solana')).toEqual({ + kind: 'open', + currency: 'KES', + }) + expect(routeCashout(parseCashout('cash out 0,500 usdc'), 'solana')).toEqual({ kind: 'ask' }) + expect(routeCashout(parseCashout('cash out 05 usdc'), 'sui')).toEqual({ kind: 'ask' }) + expect(routeCashout(parseCashout('cash out 0.5 usdc'), 'solana')).toEqual({ + kind: 'ask', + amount: '0.5', + }) + }) + + it('leaves Stellar as it was', () => { + expect(routeCashout(parseCashout('cash out 0,500 usdc to kes'), 'stellar')).toEqual({ + kind: 'open', + amount: '0500', + currency: 'KES', + }) + expect(routeCashout(parseCashout('cash out 0,500 usdc'), 'stellar')).toEqual({ + kind: 'ask', + amount: '0500', + }) + }) +}) + +describe('chainRouteFromReading', () => { + const read = (over: Partial = {}) => ({ + ...reading(), + action: 'swap', + ...over, + }) + const SENT = `swap 5 SOL to USDC then send to ${ADDRESS}` + + it('reads a sequence the model wrote out when it set no follow-on, from the persons words', () => { + expect(chainRouteFromReading(read(), SENT, [SENT])).toMatchObject({ + sequence: { followOn: { kind: 'send', recipient: ADDRESS } }, + }) + }) + + it('refuses a written-out send to an address the person never typed', () => { + const typed = ['swap 5 SOL to USDC then send it to my friend'] + expect(chainRouteFromReading(read(), SENT, typed)).toHaveProperty('refusal') + expect(chainRouteFromReading(read(), `send 5 USDC to ${ADDRESS}`, typed)).toHaveProperty( + 'refusal' + ) + }) + + it('refuses a written-out amount the person never typed', () => { + const typed = ['swap SOL to USDC then send to ' + ADDRESS] + expect(chainRouteFromReading(read(), SENT, typed)).toHaveProperty('refusal') + }) + + it('runs a plain send and a plain swap that are in the persons words', () => { + const send = `send 5 USDC to ${ADDRESS}` + expect(chainRouteFromReading(read(), send, [send])).toMatchObject({ + action: { kind: 'send', recipient: ADDRESS }, + }) + expect( + chainRouteFromReading(read(), 'Flip 5 SOL into USDC', ['flip 5 sol into usdc']) + ).toMatchObject({ action: { kind: 'swap', amount: '5', tokenIn: 'SOL', tokenOut: 'USDC' } }) + expect( + chainRouteFromReading(read({ amountUsd: 9 }), 'Flip 9 SOL into USDC', [ + 'flip 5 sol into dollars', + ]) + ).toHaveProperty('refusal') + }) + + it('reads nothing from a model that is not swapping and wrote no action', () => { + expect(chainRouteFromReading(read({ action: 'borrow' }), 'borrow some', ['borrow'])).toBeNull() + }) +}) + +describe('the output token of a model reading', () => { + const send = { kind: 'send', venue: '', recipient: ADDRESS } + + it('refuses a token out the person never named, where a send follows', () => { + const out = sequenceFromReading(reading({ tokenOut: 'BONK', followOn: send }), [ + `swap 5 SOL then send to ${ADDRESS}`, + ]) + expect(out).toHaveProperty('refusal') + expect(JSON.stringify(out)).not.toContain('BONK') + }) + + it('passes the token out when the person typed it', () => { + expect( + sequenceFromReading(reading({ followOn: send }), [ + `swap 5 SOL to USDC then send to ${ADDRESS}`, + ]) + ).toMatchObject({ swap: { tokenOut: 'USDC' } }) + }) + + it('implies USDC for a cash-out with no output named, and only then', () => { + const cash = { kind: 'offramp', venue: 'weave' } + expect(sequenceFromReading(reading({ followOn: cash }), ['cash out 5 SOL'])).toMatchObject({ + followOn: { kind: 'cashout' }, + }) + expect( + sequenceFromReading(reading({ tokenOut: 'BONK', followOn: cash }), ['cash out 5 SOL']) + ).toHaveProperty('refusal') + }) + + it('refuses a deposit and a plain swap with an output the person did not name', () => { + expect( + sequenceFromReading(reading({ followOn: { kind: 'lend', venue: 'x' } }), [ + 'swap 5 SOL then deposit', + ]) + ).toHaveProperty('refusal') + expect(swapFromReading(reading(), ['swap 5 SOL'])).toHaveProperty('refusal') + }) + + it('reads a chain name as its token out', () => { + expect( + swapFromReading(reading({ tokenIn: 'USDC', tokenOut: 'SOL' }), ['swap 5 USDC to solana']) + ).toMatchObject({ tokenOut: 'SOL' }) + expect( + swapFromReading(reading({ tokenIn: 'USDC', tokenOut: 'SOL' }), ['swap 5 USDC on solana']) + ).toHaveProperty('refusal') + }) +}) + +describe('the order of a swap in the persons words', () => { + const swap = (tokenIn: string, tokenOut: string, amountUsd = 5) => + reading({ tokenIn, tokenOut, amountUsd }) + + it('refuses a reversed swap, though each word is there', () => { + expect(swapFromReading(swap('JitoSOL', 'SOL'), ['swap 5 SOL to JitoSOL'])).toHaveProperty( + 'refusal' + ) + }) + + it('passes the same order, with the amount either side of the token', () => { + expect(swapFromReading(swap('SOL', 'JitoSOL'), ['swap 5 SOL to JitoSOL'])).toMatchObject({ + tokenIn: 'SOL', + tokenOut: 'JitoSOL', + }) + expect(swapFromReading(swap('SOL', 'USDC'), ['swap SOL 5 to USDC'])).toMatchObject({ + tokenIn: 'SOL', + }) + expect(swapFromReading(swap('SOL', 'USDC'), ['swap 5 of my sol to usdc'])).toMatchObject({ + tokenIn: 'SOL', + }) + }) + + it('refuses an amount that sits beside the other token', () => { + expect(swapFromReading(swap('SOL', 'USDC'), ['swap SOL to 5 USDC'])).toHaveProperty('refusal') + }) + + it('reads across clarified turns in order', () => { + expect(swapFromReading(swap('SOL', 'USDC'), ['swap some sol', '5', 'to usdc'])).toMatchObject({ + tokenIn: 'SOL', + tokenOut: 'USDC', + }) + expect(swapFromReading(swap('USDC', 'SOL'), ['swap some sol', '5', 'to usdc'])).toHaveProperty( + 'refusal' + ) + }) + + it('applies to a sequence, and a cash-out with no output named only needs the amount by the token', () => { + const send = { kind: 'send', venue: '', recipient: ADDRESS } + expect( + sequenceFromReading(reading({ tokenIn: 'USDC', tokenOut: 'SOL', followOn: send }), [ + `swap 5 SOL to USDC then send to ${ADDRESS}`, + ]) + ).toHaveProperty('refusal') + expect( + sequenceFromReading(reading({ followOn: { kind: 'offramp', venue: 'weave' } }), [ + 'cash out 5 SOL', + ]) + ).toMatchObject({ followOn: { kind: 'cashout' } }) + }) +}) + +describe('a venue the model adds to a plain action', () => { + const read = { ...reading(), action: 'supply' } + it('is dropped unless the person said it, keeping the action', () => { + expect( + chainRouteFromReading(read, 'supply 25 USDC on Kamino', ['supply 25 USDC']) + ).toMatchObject({ action: { kind: 'supply', amount: '25', venue: undefined } }) + expect( + chainRouteFromReading(read, 'supply 25 USDC on Kamino', ['supply 25 USDC on kamino']) + ).toMatchObject({ action: { venue: 'Kamino' } }) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts index ee5d18e9..ba9d17b5 100644 --- a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -275,6 +275,12 @@ describe('the warning that survives a reload', () => { expect(earlierAttempt(started(), { ...asking, ...change }, later(3))).toBeUndefined() }) + it('warns about a dollar amount retyped after the price moved', () => { + expect(earlierAttempt(started(), { ...asking, amount: '5.1' }, later(3))).toEqual(attempt) + expect(earlierAttempt(started(), { ...asking, amount: '4.9' }, later(3))).toEqual(attempt) + expect(earlierAttempt(started(), { ...asking, amount: '5.5' }, 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) diff --git a/apps/dapp/lib/__tests__/currency-flag.test.ts b/apps/dapp/lib/__tests__/currency-flag.test.ts new file mode 100644 index 00000000..820cfc2a --- /dev/null +++ b/apps/dapp/lib/__tests__/currency-flag.test.ts @@ -0,0 +1,35 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { currencyFlag, payoutWord } from '../weave/currency-flag' + +describe('currencyFlag', () => { + it.each([ + ['NGN', '/flags/ng.svg', 'Nigeria'], + ['KES', '/flags/ke.svg', 'Kenya'], + ['UGX', '/flags/ug.svg', 'Uganda'], + ['TZS', '/flags/tz.svg', 'Tanzania'], + ])('%s has a flag file', (code, src, country) => { + const flag = currencyFlag(code) + expect(flag).toEqual({ src, country }) + expect(existsSync(join(__dirname, '..', '..', 'public', src))).toBe(true) + }) + + it('has nothing for a code it does not know', () => { + expect(currencyFlag('XXX')).toBeUndefined() + }) +}) + +describe('payoutWord', () => { + it('names mobile money alone', () => { + expect(payoutWord(['mobile_money', 'mobile_money'])).toBe('mobile money provider') + }) + it('names banks alone', () => { + expect(payoutWord(['bank'])).toBe('bank') + }) + it('names both when mixed', () => { + expect(payoutWord(['bank', 'mobile_money'])).toBe('bank or mobile money') + }) +}) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 7c4b0048..583b30e9 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -326,13 +326,13 @@ describe('the chain modules', () => { expect(EVM_CHAINS.avalanche.chain.id).toBe(43114) }) - it('finds a module for both, each with its own venue only', () => { + it('finds a module for both, each with its own swap venue and a wallet transfer 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?.venues.map((v) => v.id)).toEqual(['1click-avalanche', 'send']) + expect(monadModule?.venues.map((v) => v.id)).toEqual(['1click-monad', 'send']) expect(avalancheModule?.defaultVenue.swap).toBe('1click-avalanche') expect(monadModule?.defaultVenue.swap).toBe('1click-monad') expect(chainModule('arc')).toBeUndefined() @@ -354,6 +354,7 @@ describe('the chain modules', () => { expect(buildEvmVenues('monad', { AUTH_SECRET: 'short' })).toEqual([]) expect(buildEvmVenues('monad', { AUTH_SECRET: 'x'.repeat(32) }).map((v) => v.id)).toEqual([ '1click-monad', + 'send', ]) }) @@ -437,7 +438,7 @@ describe('the race on these chains', () => { }) expect(capabilitySheet('avalanche')).toContain('Agents compete') expect(capabilitySheet('monad')).toContain('Agents compete') - expect(capabilitySheet('monad')).not.toMatch(/Supply:|Stake:|Send:|limit order/) + expect(capabilitySheet('monad')).not.toMatch(/Supply:|Stake:|limit order/) expect(capabilitySheet('arc')).not.toContain('Agents compete') }) @@ -503,6 +504,7 @@ describe('naming the chain you are on', () => { defaultVenue: { swap: 'beta' }, priceUsd: async () => 1, submit: async () => ({ ok: false, error: 'unused here' }), + balances: async () => ({ tokens: [], complete: true }), } } const ab = (venue: string): ChainActionRequest => ({ @@ -1728,8 +1730,13 @@ describe('a stale card, before the wallet opens', () => { 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) { + it('is stopped on NEAR too, whose wallet also sends for itself', () => { + expect(quoteExpiredBeforeSigning(card('near', '2020-01-01T00:00:00.000Z'), NOW)).toBe(true) + expect(quoteExpiredBeforeSigning(card('near'), NOW)).toBe(false) + }) + + it('is left to the server on chains whose bytes it sends', () => { + for (const family of ['solana', 'sui'] as const) { expect(quoteExpiredBeforeSigning(card(family, '2020-01-01T00:00:00.000Z'), NOW)).toBe(false) } }) diff --git a/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts b/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts new file mode 100644 index 00000000..87280e91 --- /dev/null +++ b/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { chainReadPolicy, tokenHintIn } from '../intent-read-policy' +import { readingFromArguments } from '../parse-intent-llm' + +const args = (over: Record = {}) => ({ + action: 'swap', + tokenIn: 'SOL', + tokenOut: 'USDC', + amountUsd: 5, + amountIsUsd: false, + amountStated: true, + followOn: 'none', + followOnVenue: '', + ...over, +}) + +describe('chainReadPolicy', () => { + it('has nothing for Stellar or a chain with no module', () => { + expect(chainReadPolicy('stellar')).toBeUndefined() + expect(chainReadPolicy('arc')).toBeUndefined() + }) + + it("allows the chain's own tokens, native, name words and mixed-case symbols, upper-cased", () => { + const solana = chainReadPolicy('solana') + expect(solana?.symbols).toEqual(expect.arrayContaining(['SOL', 'USDC', 'SOLANA', 'JITOSOL'])) + expect(solana?.symbols).not.toContain('XLM') + expect(chainReadPolicy('sui')?.symbols).not.toContain('SOL') + expect(chainReadPolicy('avalanche')?.symbols).toEqual( + expect.arrayContaining(['AVAX', 'AVALANCHE']) + ) + }) + + it('accepts "change my 5 sol into kes" as a SOL to USDC cash-out on Solana', () => { + const policy = chainReadPolicy('solana') + const read = readingFromArguments(args({ followOn: 'offramp' }), { + allowedSymbols: policy?.symbols as string[], + allowedVenues: policy?.venues as string[], + allowedAnchors: ['weave'], + }) + expect(read).toMatchObject({ + tokenIn: 'SOL', + tokenOut: 'USDC', + amountUsd: 5, + amountStated: true, + followOn: { kind: 'offramp', venue: 'weave' }, + }) + }) + + it('refuses SOL on Sui, and a stated amount of zero stays unreadable', () => { + const sui = chainReadPolicy('sui') + expect(readingFromArguments(args(), { allowedSymbols: sui?.symbols as string[] })).toBeNull() + const solana = chainReadPolicy('solana') + expect( + readingFromArguments(args({ amountUsd: 0 }), { allowedSymbols: solana?.symbols as string[] }) + ).toBeNull() + }) + + it('offers deposit venues from the module and no Stellar ones', () => { + expect(chainReadPolicy('solana')?.venues.length).toBeGreaterThan(0) + expect(chainReadPolicy('solana')?.venues).not.toContain('blend') + }) +}) + +describe('tokenHintIn', () => { + it('answers with the chains hint for a symbol the person wrote', () => { + expect(tokenHintIn('monad', 'swap 5 usdt to mon')).toContain('USDT0') + expect(tokenHintIn('monad', 'swap 5 USDT0 to mon')).toBeUndefined() + }) + + it('has nothing on Stellar, on a chain with no hint, or for a longer word', () => { + expect(tokenHintIn('stellar', 'swap 5 usdt to xlm')).toBeUndefined() + expect(tokenHintIn('solana', 'swap 5 usdt to sol')).toBeUndefined() + expect(tokenHintIn('monad', 'swap 5 usdtx to mon')).toBeUndefined() + }) +}) diff --git a/apps/dapp/lib/__tests__/live-session.test.ts b/apps/dapp/lib/__tests__/live-session.test.ts new file mode 100644 index 00000000..ffa6cb3d --- /dev/null +++ b/apps/dapp/lib/__tests__/live-session.test.ts @@ -0,0 +1,399 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + appendLiveTurn, + BANK_SEARCH_LABEL, + cardIsStale, + chainOutcomeSentence, + clearLiveSession, + FINISH_SIGNATURE_NOTICE, + liveSessionKey, + loadLiveSession, + ownedTurns, + ownerChange, + recordableReply, + showChatOnPath, + WALLET_SWITCHED_NOTICE, + type LiveTurn, +} from '../chat/live-session' + +function memoryStorage(): Storage { + const data = new Map() + return { + get length() { + return data.size + }, + clear: () => data.clear(), + getItem: (key) => data.get(key) ?? null, + key: (index) => [...data.keys()][index] ?? null, + removeItem: (key) => void data.delete(key), + setItem: (key, value) => void data.set(key, value), + } +} + +const turn = (text: string, at = 1): LiveTurn => ({ role: 'user', text, at }) + +describe('who a saved chat belongs to', () => { + it('never shows one account the turns of another', () => { + const storage = memoryStorage() + appendLiveTurn(storage, 'solana', 'alice', turn('mine')) + expect(loadLiveSession(storage, 'solana', 'bob')).toEqual([]) + expect(loadLiveSession(storage, 'solana', 'alice')).toEqual([turn('mine')]) + }) + + it('never shows one chain the turns of another', () => { + const storage = memoryStorage() + appendLiveTurn(storage, 'solana', 'alice', turn('mine')) + expect(loadLiveSession(storage, 'sui', 'alice')).toEqual([]) + }) + + it('gives each chain and account its own key', () => { + expect(liveSessionKey('solana', 'alice')).not.toBe(liveSessionKey('solana', 'bob')) + expect(liveSessionKey('solana', 'alice')).not.toBe(liveSessionKey('sui', 'alice')) + }) + + it('clears one account and leaves the others', () => { + const storage = memoryStorage() + appendLiveTurn(storage, 'solana', 'alice', turn('a')) + appendLiveTurn(storage, 'solana', 'bob', turn('b')) + clearLiveSession(storage, 'solana', 'alice') + expect(loadLiveSession(storage, 'solana', 'alice')).toEqual([]) + expect(loadLiveSession(storage, 'solana', 'bob')).toEqual([turn('b')]) + }) + + it('hides what was read for one wallet once another is connected', () => { + const saved = { account: 'alice', turns: [turn('mine')] } + expect(ownedTurns(saved, 'alice')).toEqual([turn('mine')]) + expect(ownedTurns(saved, 'bob')).toEqual([]) + expect(ownedTurns(saved, undefined)).toEqual([]) + expect(ownedTurns(null, 'alice')).toEqual([]) + }) +}) + +describe('how much is kept', () => { + it('keeps the newest 200, in order', () => { + const storage = memoryStorage() + for (let i = 0; i < 205; i += 1) appendLiveTurn(storage, 'solana', 'a', turn(`t${i}`, i)) + const turns = loadLiveSession(storage, 'solana', 'a') + expect(turns).toHaveLength(200) + expect(turns[0]?.text).toBe('t5') + expect(turns[199]?.text).toBe('t204') + }) + + it('caps on load as well, for a list written longer than that', () => { + const storage = memoryStorage() + const long = Array.from({ length: 250 }, (_, i) => turn(`t${i}`, i)) + storage.setItem(liveSessionKey('solana', 'a'), JSON.stringify(long)) + const turns = loadLiveSession(storage, 'solana', 'a') + expect(turns).toHaveLength(200) + expect(turns[0]?.text).toBe('t50') + }) +}) + +describe('storage that does not cooperate', () => { + const broken = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('full') + }, + removeItem: () => { + throw new Error('denied') + }, + } + + it('reads nothing and never throws', () => { + expect(loadLiveSession(broken, 'solana', 'a')).toEqual([]) + expect(loadLiveSession(undefined, 'solana', 'a')).toEqual([]) + }) + + it('writes nothing and never throws', () => { + expect(() => appendLiveTurn(broken, 'solana', 'a', turn('x'))).not.toThrow() + expect(() => appendLiveTurn(undefined, 'solana', 'a', turn('x'))).not.toThrow() + expect(() => clearLiveSession(broken, 'solana', 'a')).not.toThrow() + expect(() => clearLiveSession(undefined, 'solana', 'a')).not.toThrow() + }) + + it('reads malformed JSON as nothing', () => { + const storage = memoryStorage() + storage.setItem(liveSessionKey('solana', 'a'), '{not json') + expect(loadLiveSession(storage, 'solana', 'a')).toEqual([]) + }) + + it('reads JSON that is not a list as nothing', () => { + const storage = memoryStorage() + storage.setItem(liveSessionKey('solana', 'a'), '{"role":"user"}') + expect(loadLiveSession(storage, 'solana', 'a')).toEqual([]) + }) + + it('starts again over malformed JSON rather than losing the new turn', () => { + const storage = memoryStorage() + storage.setItem(liveSessionKey('solana', 'a'), '{not json') + appendLiveTurn(storage, 'solana', 'a', turn('fresh')) + expect(loadLiveSession(storage, 'solana', 'a')).toEqual([turn('fresh')]) + }) +}) + +describe('what a saved turn can hold', () => { + function loaded(entries: unknown[]): LiveTurn[] { + const storage = memoryStorage() + storage.setItem(liveSessionKey('solana', 'a'), JSON.stringify(entries)) + return loadLiveSession(storage, 'solana', 'a') + } + + it('drops every field but the four it knows', () => { + const [restored] = loaded([ + { + role: 'agent', + text: 'Sent on Jupiter.', + at: 5, + explorerUrl: 'https://solscan.io/tx/abc', + signing: { family: 'solana', transaction: 'AQAB' }, + route: { venue: 'jupiter' }, + depositAddress: 'So1anaAddress', + prepared: { pay: { amount: '1' } }, + }, + ]) + expect(restored).toEqual({ + role: 'agent', + text: 'Sent on Jupiter.', + at: 5, + explorerUrl: 'https://solscan.io/tx/abc', + }) + }) + + it('never writes an unknown field either', () => { + const storage = memoryStorage() + const signable = { ...turn('hi'), signing: { transaction: 'AQAB' } } + appendLiveTurn(storage, 'solana', 'a', signable) + expect(storage.getItem(liveSessionKey('solana', 'a'))).not.toContain('AQAB') + }) + + it.each([ + 'http://solscan.io/tx/abc', + 'javascript:alert(1)', + 'data:text/html,hi', + '//solscan.io/tx/abc', + 'not a url', + 7, + ])('drops the link %s and keeps the words', (explorerUrl) => { + expect(loaded([{ role: 'agent', text: 'Sent.', at: 1, explorerUrl }])).toEqual([ + { role: 'agent', text: 'Sent.', at: 1 }, + ]) + }) + + it('does not write a link that is not https', () => { + const storage = memoryStorage() + appendLiveTurn(storage, 'solana', 'a', { ...turn('x'), explorerUrl: 'javascript:alert(1)' }) + expect(storage.getItem(liveSessionKey('solana', 'a'))).not.toContain('javascript') + }) + + it('drops an entry that is not a turn', () => { + expect( + loaded([ + null, + 'text', + { role: 'system', text: 'x', at: 1 }, + { role: 'user', at: 1 }, + { role: 'user', text: 'no time' }, + { role: 'user', text: 'x', at: Number.NaN }, + { role: 'user', text: 'kept', at: 2 }, + ]) + ).toEqual([{ role: 'user', text: 'kept', at: 2 }]) + }) +}) + +describe('where the chat shows', () => { + it.each(['/solana/intents', '/solana/intents/new'])('shows on %s', (path) => { + expect(showChatOnPath(path, 'solana')).toBe(true) + }) + + it.each(['/solana/intents/abc', '/solana/agents', '/solana/intentsx', '/sui/intents', '/'])( + 'does not show on %s', + (path) => { + expect(showChatOnPath(path, 'solana')).toBe(false) + } + ) + + it('matches the network segment Stellar sits under', () => { + expect(showChatOnPath('/stellar-testnet/intents', 'stellar-testnet')).toBe(true) + expect(showChatOnPath('/stellar-mainnet/intents', 'stellar-testnet')).toBe(false) + }) +}) + +describe('the one sentence a chain race settles to', () => { + it('says nothing while the race is still open', () => { + for (const phase of ['idle', 'building', 'review', 'signing', 'submitting']) { + expect(chainOutcomeSentence({ phase, venueName: 'Jupiter' })).toBeUndefined() + } + }) + + it('names the venue and that it was sent', () => { + expect(chainOutcomeSentence({ phase: 'settled', venueName: 'Jupiter' })).toBe( + 'Sent on Jupiter.' + ) + }) + + it('warns against sending again when the outcome is not known', () => { + expect( + chainOutcomeSentence({ phase: 'pending', venueName: 'Jupiter', error: 'Not known yet.' }) + ).toBe( + "The transaction on Jupiter may have gone through, so check your wallet's activity before trying again." + ) + }) + + it('names the venue and repeats what the card said when it failed', () => { + expect( + chainOutcomeSentence({ phase: 'failed', venueName: 'Jupiter', error: 'You declined it.' }) + ).toBe('On Jupiter: You declined it.') + }) + + it('still says something when a failure gave no reason', () => { + expect(chainOutcomeSentence({ phase: 'failed', venueName: 'Jupiter' })).toBe( + 'On Jupiter: This could not be finished.' + ) + }) + + it('repeats a refusal that never reached a venue', () => { + expect(chainOutcomeSentence({ phase: 'failed', error: 'A cash-out is under way.' })).toBe( + 'A cash-out is under way.' + ) + expect(chainOutcomeSentence({ phase: 'failed' })).toBeUndefined() + }) +}) + +describe('another wallet connecting over an open chat', () => { + it('starts that wallet fresh when nothing is in flight', () => { + expect(ownerChange('alice', 'bob', false)).toBe('reset') + }) + + it('waits while a signature is open, since resetting would orphan it', () => { + expect(ownerChange('alice', 'bob', true)).toBe('defer') + }) + + it.each([ + ['the same wallet', 'alice', 'alice'], + ['the same hex address in another case', '0xAbC1', '0xaBc1'], + ['a first connection', undefined, 'alice'], + ['a wallet going away', 'alice', undefined], + ['nobody at all', undefined, undefined], + ])('leaves the chat alone for %s', (_name, previous, next) => { + expect(ownerChange(previous, next, false)).toBe('keep') + expect(ownerChange(previous, next, true)).toBe('keep') + }) + + it('treats case as part of an address that is not hex', () => { + expect(ownerChange('SoLaNa', 'solana', false)).toBe('reset') + }) +}) + +describe('what is said without being a reply', () => { + it('keeps a notice about a signature out of the transcript', () => { + expect(recordableReply(FINISH_SIGNATURE_NOTICE)).toBe(false) + expect(recordableReply(WALLET_SWITCHED_NOTICE)).toBe(false) + }) + + it('says what a wallet switch waits on in words true after a cash-out is paid', () => { + expect(WALLET_SWITCHED_NOTICE).toBe( + 'Your wallet changed. The payment under way here has to finish first.' + ) + }) + + it('keeps a reply', () => { + expect(recordableReply('You can swap and send.')).toBe(true) + }) +}) + +describe('a card still open when the person comes back', () => { + const shownAt = 1_000_000 + const minute = 60_000 + + it('goes stale at its own deadline', () => { + const deadline = new Date(shownAt + 30 * minute).toISOString() + expect(cardIsStale({ deadline, shownAt }, shownAt + 29 * minute)).toBe(false) + expect(cardIsStale({ deadline, shownAt }, shownAt + 30 * minute)).toBe(true) + }) + + it('goes stale after five minutes when it names no deadline', () => { + expect(cardIsStale({ shownAt }, shownAt + 5 * minute)).toBe(false) + expect(cardIsStale({ shownAt }, shownAt + 5 * minute + 1)).toBe(true) + }) + + it('falls back to five minutes on a deadline it cannot read', () => { + expect(cardIsStale({ deadline: 'soon', shownAt }, shownAt + minute)).toBe(false) + expect(cardIsStale({ deadline: 'soon', shownAt }, shownAt + 6 * minute)).toBe(true) + }) +}) + +describe('the words a saved turn keeps', () => { + function saved(text: string): string | undefined { + const storage = memoryStorage() + appendLiveTurn(storage, 'solana', 'a', turn(text)) + return loadLiveSession(storage, 'solana', 'a')[0]?.text + } + + it('hides all but the last four digits of a long number', () => { + expect(saved('0123456789')).toBe('••••6789') + expect(saved('send it to 0123456789 at GTBank')).toBe('send it to ••••6789 at GTBank') + expect(saved('12345678')).toBe('••••5678') + }) + + it('leaves short numbers alone', () => { + expect(saved('5.05 USDC')).toBe('5.05 USDC') + expect(saved('1234567')).toBe('1234567') + }) + + it('leaves an address alone when it holds no long run of digits', () => { + const hex = '0xAbCd1234EfAb5678CdEf9012AbCd3456EfAb7890' + const base58 = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV' + expect(saved(hex)).toBe(hex) + expect(saved(base58)).toBe(base58) + }) + + it('masks what was already stored, on load', () => { + const storage = memoryStorage() + storage.setItem( + liveSessionKey('solana', 'a'), + JSON.stringify([{ role: 'user', text: 'acct 0123456789', at: 1 }]) + ) + expect(loadLiveSession(storage, 'solana', 'a')[0]?.text).toBe('acct ••••6789') + }) + + it('stays the same when saved and read again', () => { + const storage = memoryStorage() + appendLiveTurn(storage, 'solana', 'a', turn('0123456789')) + appendLiveTurn(storage, 'solana', 'a', turn('next')) + expect(loadLiveSession(storage, 'solana', 'a')[0]?.text).toBe('••••6789') + }) + + it('cuts a long paste at 2000 characters', () => { + expect(saved('a'.repeat(5000))).toHaveLength(2000) + expect(saved('a'.repeat(2000))).toHaveLength(2000) + }) + + it('cuts on load as well', () => { + const storage = memoryStorage() + storage.setItem( + liveSessionKey('solana', 'a'), + JSON.stringify([{ role: 'user', text: 'a'.repeat(3000), at: 1 }]) + ) + expect(loadLiveSession(storage, 'solana', 'a')[0]?.text).toHaveLength(2000) + }) +}) + +describe('closing the bank list from outside it', () => { + const read = (file: string): string => + readFileSync(join(__dirname, '../../components/intents', file), 'utf8') + + it('finds the label the picker really uses, so a rename fails here', () => { + expect(read('bank-picker.tsx')).toContain(`aria-label="${BANK_SEARCH_LABEL}"`) + }) + + it('queries by that label', () => { + expect(read('chat-host.tsx')).toContain('input[aria-label="${BANK_SEARCH_LABEL}"]') + }) +}) diff --git a/apps/dapp/lib/__tests__/near-venues.test.ts b/apps/dapp/lib/__tests__/near-venues.test.ts index e5c44d5c..8d0aa634 100644 --- a/apps/dapp/lib/__tests__/near-venues.test.ts +++ b/apps/dapp/lib/__tests__/near-venues.test.ts @@ -43,7 +43,9 @@ describe('the NEAR venue list', () => { it('claims execution exactly for the venues the module wires', () => { const claimed = nearVenues.filter((v) => v.integration === 'executes').map((v) => v.id) - expect(claimed.sort()).toEqual(nearModule.venues.map((v) => v.id).sort()) + // A wallet transfer is not an app, so the Apps list has no entry for it. + const wired = nearModule.venues.map((v) => v.id).filter((id) => id !== 'send') + expect(claimed.sort()).toEqual(wired.sort()) for (const venue of nearVenues) { if (venue.integration === 'executes') expect(venue.capability).toBeTruthy() else expect(venue.capability).toBeUndefined() diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index 575e44d6..a567b763 100644 --- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -1,6 +1,9 @@ +import { createHash } from 'node:crypto' + import { afterEach, describe, expect, it, vi } from 'vitest' import { capabilitySheet } from '../orchestrate/capabilities' +import { orchestrateWithLlm } from '../orchestrate/orchestrate' describe('capabilitySheet per chain', () => { it('keeps Stellar as it was', () => { @@ -15,7 +18,7 @@ describe('capabilitySheet per chain', () => { expect(sheet).toContain('Agents compete') expect(sheet).toContain('Supply:') expect(sheet).toContain('Stake:') - expect(sheet).not.toMatch(/Send:|Accumulate|Hedge|limit order/) + expect(sheet).not.toMatch(/Accumulate|Hedge|limit order/) }) it('says agents compete on Solana and Sui now their venues can be quoted', () => { @@ -79,6 +82,153 @@ describe('capabilitySheet reads what the chain can run on this server', () => { 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) + expect(lines).toHaveLength(5) + }) +}) + +describe('capabilitySheet multi-step forms', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + async function sheetWith(weave: string): Promise { + vi.resetModules() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) + vi.stubEnv('WEAVE_SECRET_KEY', weave) + return (await import('../orchestrate/capabilities')).capabilitySheet + } + + it('says swap then cash out runs on Solana, Sui and Avalanche', async () => { + const sheet = await sheetWith('k') + for (const chain of ['solana', 'sui', 'avalanche']) { + expect(sheet(chain)).toMatch(/Swap then cash out:/) + } + expect(sheet('solana')).toMatch(/Swap then deposit:/) + expect(sheet('solana')).toMatch(/Swap then send:/) + }, 60_000) + + it('promises no cash-out where Weave is not set up', async () => { + const sheet = await sheetWith('') + expect(sheet('solana')).not.toMatch(/cash out/i) + }, 60_000) + + it('says NEAR runs swap then deposit and send, and why cash-out and stake do not', async () => { + const sheet = await sheetWith('k') + const near = sheet('near') + expect(near).toMatch(/Swap then deposit:/) + expect(near).toMatch(/Swap then send:/) + expect(near).not.toMatch(/Swap then cash out:/) + expect(near).toContain("Weave's NEAR deposits aren't working yet") + expect(near).toMatch(/stake after a swap is not available on NEAR/i) + }, 60_000) + + it('says Monad runs swap and send, and not cash-out or deposit', async () => { + const sheet = await sheetWith('k') + const monad = sheet('monad') + expect(monad).toMatch(/Swap then send:/) + expect(monad).not.toMatch(/Swap then (cash out|deposit):/) + expect(monad).toMatch(/Cash out and deposit after a swap do not run on Monad/) + }, 60_000) + + it('says which native coins cannot be sent yet', async () => { + const sheet = await sheetWith('k') + expect(sheet('solana')).toContain("Native SOL can't be sent yet") + expect(sheet('sui')).toContain("Native SUI can't be sent yet") + expect(sheet('near')).toContain("Native NEAR can't be sent yet") + expect(sheet('avalanche')).not.toContain("can't be sent yet") + }, 60_000) + + it('keeps limit orders listed as not available, and lists every token in a balance', async () => { + const sheet = await sheetWith('k') + for (const chain of ['solana', 'sui', 'near', 'avalanche', 'monad']) { + expect(sheet(chain)).toContain('Limit orders are not available off Stellar') + expect(sheet(chain)).toMatch(/Balance:/) + expect(sheet(chain)).not.toContain('unlisted') + } + }, 60_000) + + it('promises no send without the secret that issues it', async () => { + vi.resetModules() + vi.stubEnv('AUTH_SECRET', '') + vi.stubEnv('WEAVE_SECRET_KEY', 'k') + const { capabilitySheet: sheet } = await import('../orchestrate/capabilities') + expect(sheet('solana')).not.toMatch(/Send:|then send/) + expect(sheet('near')).not.toMatch(/Send:|then send/) + }, 60_000) +}) + +describe('the orchestrator prompt', () => { + afterEach(() => vi.unstubAllEnvs()) + + async function systemFor(chain: string): Promise { + let system = '' + const fetchImpl = (async (_url: string, init: { body: string }) => { + system = (JSON.parse(init.body) as { messages: { content: string }[] }).messages[0] + ?.content as string + return new Response('{}', { status: 200 }) + }) as unknown as typeof fetch + await orchestrateWithLlm([{ role: 'user', content: 'hi' }], { + apiKey: 'k', + chain, + fetchImpl, + }) + return system + } + + it('is byte for byte what it was on Stellar', async () => { + vi.stubEnv('WEAVE_SECRET_KEY', '') + const system = await systemFor('stellar') + expect(system.length).toBe(6270) + expect(createHash('sha256').update(system).digest('hex')).toBe( + '06762643ae6b219a91581fc48e65b67563bb70062f2761e60a8a5d3aeae9be1b' + ) + }) + + it('says off Stellar what is true there about follow-ons', async () => { + const system = await systemFor('solana') + expect(system).toMatch(/not on Stellar/) + expect(system).toContain('The tokens on this chain are SOL, USDC') + expect((await systemFor('sui')).includes('JitoSOL')).toBe(false) + expect(system).toMatch(/character for character/) + expect(system).toMatch(/Swap 5 SOL to USDC, then send to/) }) }) + +describe('balances are described as each chain reads them', () => { + it('says every token where the chain reads every token, and the known ones elsewhere', async () => { + vi.resetModules() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) + const { capabilitySheet: sheet } = await import('../orchestrate/capabilities') + for (const chain of ['solana', 'sui', 'near']) { + expect(sheet(chain)).toMatch(/Balance:.*every token it holds/) + } + for (const chain of ['avalanche', 'monad']) { + expect(sheet(chain)).toMatch(/Balance:.*the tokens Intent knows/) + expect(sheet(chain)).not.toContain('every token') + } + vi.unstubAllEnvs() + }, 60_000) +}) + +describe('the prompt off Stellar', () => { + afterEach(() => vi.unstubAllEnvs()) + + it.each(['solana', 'sui', 'near', 'avalanche', 'monad'])( + 'says nothing of Stellar things on %s, except what is not available', + async (chain) => { + let system = '' + const fetchImpl = (async (_url: string, init: { body: string }) => { + system = (JSON.parse(init.body) as { messages: { content: string }[] }).messages[0] + ?.content as string + return new Response('{}', { status: 200 }) + }) as unknown as typeof fetch + await orchestrateWithLlm([{ role: 'user', content: 'hi' }], { apiKey: 'k', chain, fetchImpl }) + const said = system + .replace('Limit orders are not available off Stellar.', '') + .replace('The person is on the chain named in the facts below, not on Stellar.', '') + expect(said).not.toMatch(/(xlm|lumens?|stellar|blend|defindex|anchors?)/i) + expect(said).not.toContain('.xlm') + } + ) +}) diff --git a/apps/dapp/lib/__tests__/orchestrate-route.test.ts b/apps/dapp/lib/__tests__/orchestrate-route.test.ts index 2d7499f8..cb8a87bf 100644 --- a/apps/dapp/lib/__tests__/orchestrate-route.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-route.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const orchestrateWithLlm = vi.fn() const isLlmParseConfigured = vi.fn() @@ -15,8 +15,9 @@ vi.mock('../server/analytics', () => ({ logIntentRead: (...args: unknown[]) => logIntentRead(...args), })) vi.mock('../lend/venues', () => ({ configuredLendingVenues: () => ['blend'] })) +const ensureLiveAssets = vi.fn(async () => undefined) vi.mock('../swap/live-assets', () => ({ - ensureLiveAssets: async () => undefined, + ensureLiveAssets: (...args: unknown[]) => ensureLiveAssets(...(args as [])), tickersIn: () => [], })) vi.mock('../swap/asset-registry', () => ({ @@ -48,6 +49,11 @@ async function route() { return import('../../app/api/intent/orchestrate/route') } +// The route now loads the chain modules, which takes a few seconds the first time. +beforeAll(async () => { + await route() +}, 60_000) + beforeEach(() => { vi.clearAllMocks() isLlmParseConfigured.mockReturnValue(true) @@ -181,3 +187,48 @@ describe('POST /api/intent/orchestrate', () => { expect(orchestrateWithLlm).not.toHaveBeenCalled() }) }) + +describe('the vocabulary a reading is held to', () => { + const act = { status: 'act', reading, style: 'market', instruction: 'x', context: [] } + const optionsOf = (): Record => + orchestrateWithLlm.mock.calls[0]?.[1] as Record + + it('keeps Stellar on its own registry, venues and anchors', async () => { + orchestrateWithLlm.mockResolvedValue(act) + const { POST } = await route() + await POST(post({ turns: turns('swap 5 xlm to usdc'), chain: 'stellar' })) + expect(optionsOf()).toMatchObject({ + allowedSymbols: ['XLM', 'USDC', 'WBTC', 'WETH'], + allowedVenues: ['blend'], + allowedAnchors: ['testanchor', 'moneygram'], + }) + expect(ensureLiveAssets).toHaveBeenCalled() + expect(optionsOf()).not.toHaveProperty('reportRefusedFollowOn') + }) + + it('asks to be told of a refused follow-on off Stellar, and passes it on', async () => { + const followOnRefused = { kind: 'lend', venue: 'aave' } + orchestrateWithLlm.mockResolvedValue({ + ...act, + reading: { ...reading, tokenIn: 'SOL', amountIsUsd: false, followOnRefused }, + }) + const { POST } = await route() + const res = await POST(post({ turns: turns('x'), chain: 'solana' })) + expect(optionsOf()['reportRefusedFollowOn']).toBe(true) + expect(await res.json()).toMatchObject({ followOn: null, followOnRefused }) + }) + + it("uses the chain's tokens off Stellar and never asks the Stellar registry", async () => { + orchestrateWithLlm.mockResolvedValue({ + ...act, + reading: { ...reading, tokenIn: 'SOL', amountIsUsd: false }, + }) + const { POST } = await route() + const res = await POST(post({ turns: turns('change my 5 sol into kes'), chain: 'solana' })) + expect(optionsOf()['allowedSymbols']).toEqual(expect.arrayContaining(['SOL', 'USDC'])) + expect(optionsOf()['allowedSymbols']).not.toContain('XLM') + expect(optionsOf()['allowedVenues']).not.toContain('blend') + expect(ensureLiveAssets).not.toHaveBeenCalled() + expect(await res.json()).toMatchObject({ outcome: 'act', tokenIn: 'SOL', liveAssets: [] }) + }) +}) diff --git a/apps/dapp/lib/__tests__/rate-limit.test.ts b/apps/dapp/lib/__tests__/rate-limit.test.ts index 7e0d026c..ed7700a9 100644 --- a/apps/dapp/lib/__tests__/rate-limit.test.ts +++ b/apps/dapp/lib/__tests__/rate-limit.test.ts @@ -98,13 +98,27 @@ describe('createRateLimiter', () => { describe('limitFor', () => { it('has a default for every family', () => { - for (const family of ['build', 'submit', 'compete', 'resolve', 'standing', 'auth'] as const) { + for (const family of [ + 'build', + 'read', + 'submit', + 'compete', + 'resolve', + 'standing', + 'auth', + ] as const) { expect(limitFor(family, {})).toEqual(RATE_LIMIT_DEFAULTS[family]) expect(RATE_LIMIT_DEFAULTS[family].limit).toBeGreaterThan(0) expect(RATE_LIMIT_DEFAULTS[family].windowSeconds).toBeGreaterThan(0) } }) + it('gives balance reads their own, wider family, apart from what builds', () => { + expect(RATE_LIMIT_DEFAULTS.read).toEqual({ limit: 120, windowSeconds: 60 }) + expect(limitFor('read', { RATE_LIMIT_READ: '5/10' })).toEqual({ limit: 5, windowSeconds: 10 }) + expect(limitFor('build', { RATE_LIMIT_READ: '5/10' })).toEqual(RATE_LIMIT_DEFAULTS.build) + }) + it('reads RATE_LIMIT_ as count/seconds', () => { expect(limitFor('build', { RATE_LIMIT_BUILD: '5/10' })).toEqual({ limit: 5, windowSeconds: 10 }) expect(limitFor('auth', { RATE_LIMIT_AUTH: ' 100 / 3600 ' })).toEqual({ @@ -124,7 +138,7 @@ describe('limitFor', () => { describe('dailyLimitFor', () => { it('has a daily cap only for compete', () => { expect(dailyLimitFor('compete', {})).toEqual(RATE_LIMIT_DAILY.compete) - for (const family of ['build', 'submit', 'resolve', 'standing', 'auth'] as const) { + for (const family of ['build', 'read', 'submit', 'resolve', 'standing', 'auth'] as const) { expect(dailyLimitFor(family, {})).toBeUndefined() } }) diff --git a/apps/dapp/lib/__tests__/route-rate-limit-get.test.ts b/apps/dapp/lib/__tests__/route-rate-limit-get.test.ts index 529880c5..7a963af7 100644 --- a/apps/dapp/lib/__tests__/route-rate-limit-get.test.ts +++ b/apps/dapp/lib/__tests__/route-rate-limit-get.test.ts @@ -23,6 +23,18 @@ const ROUTES: Record Promise> = { sponsor: () => import('../../app/api/sponsor/route') as unknown as Promise, } +describe('the balances route', () => { + it('counts against reads, not against what builds', async () => { + const { enforceRateLimit } = await import('../server/rate-limit') + const { GET } = await import('../../app/api/chain/[chain]/balances/route') + const res = await GET(new Request('http://localhost/api/chain/solana/balances'), { + params: Promise.resolve({ chain: 'solana' }), + }) + expect(res.status).toBe(429) + expect(enforceRateLimit).toHaveBeenLastCalledWith(expect.anything(), 'read') + }, 30_000) +}) + describe('the limiter runs first on GET routes that call out', () => { for (const [path, load] of Object.entries(ROUTES)) { it(`api/${path}`, async () => { diff --git a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts index abd44e4f..35ee3c0d 100644 --- a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts +++ b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts @@ -426,11 +426,12 @@ describe('the race on these chains', () => { expect(buildSuiVenues(env).map((v) => v.id)).not.toContain('1click-sui') }) - it('gives every venue on these chains a quote to race with', () => { + it('gives every venue on these chains a quote to race with, but a wallet transfer', () => { 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') + if (venue.kinds.includes('send')) expect(venue.quote, venue.id).toBeUndefined() + else expect(venue.quote, venue.id).toBeTypeOf('function') } }) diff --git a/apps/dapp/lib/__tests__/solana-venues.live.test.ts b/apps/dapp/lib/__tests__/solana-venues.live.test.ts index d7f6cd1e..0ae4d78f 100644 --- a/apps/dapp/lib/__tests__/solana-venues.live.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.live.test.ts @@ -37,6 +37,7 @@ function moduleWith(venue: ChainVenue): ChainModule { submit: async () => { throw new Error('This test must never send anything.') }, + balances: async () => ({ tokens: [], complete: true }), } } diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index 8ef5ec96..c42f7f9d 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -670,6 +670,7 @@ describe('module', () => { '1click-solana', 'jupiter-lend', 'kamino', + 'send', ]) }) diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index 42d0e5f0..01863a6e 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -289,6 +289,7 @@ describe('the module', () => { '1click-sui', 'navi', 'springsui', + 'send', ]) }) @@ -298,12 +299,17 @@ describe('the module', () => { ['navi', ['supply']], ['springsui', ['stake']], ]) - expect(suiModule.defaultVenue).toEqual({ swap: 'cetus', supply: 'navi', stake: 'springsui' }) + expect(suiModule.defaultVenue).toEqual({ + swap: 'cetus', + supply: 'navi', + stake: 'springsui', + send: 'send', + }) }) it('offers one capability per wired kind', () => { expect(suiCapabilities).toEqual([ - { kind: 'swap', venue: 'Cetus', symbol: 'SUI' }, + { kind: 'swap', venue: 'Cetus', symbol: 'SUI', then: ['cashout', 'supply', 'send'] }, { kind: 'supply', venue: 'NAVI', symbol: 'USDC' }, { kind: 'stake', venue: 'SpringSui', symbol: 'SUI' }, ]) diff --git a/apps/dapp/lib/__tests__/weave-cashout-view.test.ts b/apps/dapp/lib/__tests__/weave-cashout-view.test.ts index e7e34f88..8a5a2d24 100644 --- a/apps/dapp/lib/__tests__/weave-cashout-view.test.ts +++ b/apps/dapp/lib/__tests__/weave-cashout-view.test.ts @@ -358,7 +358,7 @@ describe('what may be offered', () => { describe('words', () => { it('says a token must be swapped to USDC first', () => { - const reply = unsupportedTokenReply('SOL') + const reply = unsupportedTokenReply('SOL', 'solana') expect(reply).toMatch(/USDC/) expect(reply).toMatch(/swap/i) expect(reply).toMatch(/SOL/) diff --git a/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts index effa8018..e917f43c 100644 --- a/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts +++ b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts @@ -106,9 +106,11 @@ describe('parseCashout', () => { it('keeps the currency beside an unsupported token', () => { expect(parseCashout('cash out 5 xlm to kes')).toEqual({ unsupportedToken: 'XLM', + amount: '5', currency: 'KES', }) - expect(parseCashout('cash out 5 sol')).toEqual({ unsupportedToken: 'SOL' }) + expect(parseCashout('cash out 5 sol')).toEqual({ unsupportedToken: 'SOL', amount: '5' }) + expect(parseCashout('cash out sol')).toEqual({ unsupportedToken: 'SOL' }) }) it.each([ @@ -198,8 +200,43 @@ describe('routeCashout', () => { }) }) + it("runs the person's own sentence, reading the chain name as its token", () => { + expect(routeCashout(parseCashout('Offramp 0.01477 solana'), 'solana')).toEqual({ + kind: 'sequence', + token: 'SOL', + amount: '0.01477', + }) + expect(routeCashout(parseCashout('cash out 5 avalanche to kes'), 'avalanche')).toEqual({ + kind: 'sequence', + token: 'AVAX', + amount: '5', + currency: 'KES', + }) + }) + + it('changes nothing on Stellar for a chain name', () => { + expect(routeCashout(parseCashout('Offramp 0.01477 solana'), 'stellar')).toEqual({ + kind: 'pass', + }) + expect(routeCashout(parseCashout('Offramp 0.01477 solana to kes'), 'stellar')).toEqual({ + kind: 'token', + token: 'SOLANA', + }) + }) + + it('leaves what the parser reads, and a refusal elsewhere, as they were', () => { + expect(parseCashout('Offramp 0.01477 solana')).toEqual({ + unsupportedToken: 'SOLANA', + amount: '0.01477', + }) + expect(routeCashout(parseCashout('cash out 5 solana'), 'near')).toEqual({ + kind: 'token', + token: 'SOLANA', + }) + }) + it.each(['near', 'solana', 'sui', 'avalanche'])('answers refusals on %s', (chain) => { - expect(routeCashout(read('cash out 5 sol'), chain)).toEqual({ kind: 'token', token: 'SOL' }) + expect(routeCashout(read('cash out sol'), chain)).toEqual({ kind: 'token', token: 'SOL' }) expect(routeCashout(read('cash out 5 usdc to brl'), chain)).toEqual({ kind: 'currency', currency: 'BRL', diff --git a/apps/dapp/lib/agents/brief.ts b/apps/dapp/lib/agents/brief.ts index 8554668f..8b81ab28 100644 --- a/apps/dapp/lib/agents/brief.ts +++ b/apps/dapp/lib/agents/brief.ts @@ -75,7 +75,9 @@ ${STRATEGIST_BRIEF}` */ 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. +This is mainnet and the funds are real funds. The action is a single transaction, signed once by the user, and nothing waits for a better price. + +You judge this action and nothing else. If the person also asked for something after it, such as a cash-out to a bank, a deposit or a send, the app runs that as its own next step once this one settles. Do not say it is unavailable, do not call it a separate job for the person, and do not mention anchors. 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. diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index 008e62e3..05709685 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -1,3 +1,4 @@ +import { QUOTE_EXPIRED, START_AGAIN } from '../chains/signing-flow' import type { ChainActionRequest } from '../chains/types' import type { CompetitionErrorCode } from './competition' import type { ChainRouteQuote } from './chain-routes' @@ -95,9 +96,25 @@ export function chainReviewClosed(phase: ChainActionPhaseName, signed: 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') +/** + * Whether the race is over for choosing. A signature closes it, and so does a sequence whose swap + * settled: step 2's press lifts the latch for step 2's own card, and the race on screen is still + * the swap's, so one more Review there would build that swap a second time. + */ +export function raceClosed(signed: boolean, sequenceSettled: boolean): boolean { + return signed || sequenceSettled +} + +/** + * The direct-route note is true only while nothing has been signed. Step 2's card follows a swap + * that has been, whichever route that swap took. + */ +export function showDirectNote( + direct: boolean, + phase: ChainActionPhaseName, + sequenceSettled: boolean = false +): boolean { + return direct && !sequenceSettled && (phase === 'building' || phase === 'review') } /** The agent that is shown as selected holds only while a build or its aftermath is live. */ @@ -222,6 +239,68 @@ export function recommendationLine( return `Recommended: ${name}, ${why}. You pick who executes.` } +const UNANSWERED_REASON: Readonly> = { + rate_limited: 'rate limited', + timeout: 'too slow', + upstream_error: 'provider error', + invalid_schema: 'unusable answer', + refused: 'declined', + no_api_key: 'not set up', +} + +/** Who left the race and why, so a short race explains itself. Nothing if everyone answered. */ +export function unansweredLine( + agents: readonly { name: string; failed?: string | undefined }[] +): string | undefined { + const gone = agents.filter((a) => a.failed !== undefined) + if (gone.length === 0) return undefined + const names = gone + .map((a) => `${a.name} (${UNANSWERED_REASON[a.failed as string] ?? 'no answer'})`) + .join(', ') + return `${gone.length} ${gone.length === 1 ? 'agent' : 'agents'} didn't answer: ${names}.` +} + +export const STEP_NOT_STARTED = + 'This step did not start. The swap stands, and what arrived stays in your wallet.' + +/** An expired card proved nothing moved, so the sequence is back at `ready` with its button. */ +export const STEP_CARD_EXPIRED = + "This step's card expired. Press the button above for a fresh one. The swap stands, so don't send your request again." + +/** A wallet that gave no answer may have sent step 2, so there is no button at this point. */ +const STEP_UNCONFIRMED = + "The swap stands, so don't send your request again. If this step did not go out, what arrived stays in your wallet." + +function stepUnconfirmed(typedStep: string | undefined): string { + return typedStep === undefined + ? STEP_UNCONFIRMED + : `The swap stands, so don't send your request again. Check your wallet. If nothing went out, you can type the step on its own, for example "${typedStep}".` +} + +/** + * A refusal of step 2, once the swap before it was signed. The server words its refusals as if + * nothing had been, which is true of step 2 and false of the sequence. Sending the request again + * would offer the swap a second time, so nothing here tells the person to. `typedStep` is step 2 as + * a request of its own, which is what they can type instead. + */ +export function stepErrorMessage( + message: string | undefined, + sequenceSettled: boolean, + typedStep?: string | undefined +): string | undefined { + if (message === undefined || !sequenceSettled) return message + if (message === QUOTE_EXPIRED) return STEP_CARD_EXPIRED + if (message.endsWith(START_AGAIN)) { + return `${message.slice(0, -START_AGAIN.length)}${stepUnconfirmed(typedStep)}` + } + if (!/nothing was signed or sent/i.test(message)) return message + const rest = message + .replace(/,\s*so nothing was signed or sent(?:,\s*so try again[^.]*)?\.?/gi, '.') + .replace(/\s*Nothing was signed or sent(?:,\s*so try again[^.]*)?\.?/g, '') + .trim() + return `${rest} ${STEP_NOT_STARTED}` +} + /** * 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, diff --git a/apps/dapp/lib/chains/balance-reply.ts b/apps/dapp/lib/chains/balance-reply.ts new file mode 100644 index 00000000..c88f78cc --- /dev/null +++ b/apps/dapp/lib/chains/balance-reply.ts @@ -0,0 +1,69 @@ +import type { TokenBalance, WalletBalances } from './types' + +const MAX_LISTED = 30 +const NOT_READ = "I could not read your balance just now, so I won't guess. Try again in a moment." + +function shortId(id: string): string { + return id.length > 12 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id +} + +function line(token: TokenBalance): string { + if (token.listed || token.native) return `• ${token.amount} ${token.symbol}` + return token.symbol === token.id + ? `• ${token.amount} ${shortId(token.id)}` + : `• ${token.amount} ${token.symbol} (${shortId(token.id)})` +} + +function isZero(amount: string): boolean { + return /^0(\.0*)?$/.test(amount) +} + +export interface BalanceAsk { + chain: string + account: string | undefined + /** Counts messages sent, so a reply to an older one can be told from the latest. */ + turn: number +} + +/** A late answer is shown only if the person is still on the chain, wallet and message it was for. */ +export function balanceReplyIsCurrent(asked: BalanceAsk, now: BalanceAsk): boolean { + return asked.account === now.account && asked.chain === now.chain && asked.turn === now.turn +} + +export const WALLET_CHANGED_NOTICE = 'Your wallet changed, so ask again for its balance.' + +export type BalanceReplyDecision = + | { kind: 'show' } + /** The only message's reply is no longer for this wallet, so one line says why. */ + | { kind: 'notice'; text: string } + /** A newer message or a cleared chat owns the screen. */ + | { kind: 'drop' } + +export function balanceReplyDecision(asked: BalanceAsk, now: BalanceAsk): BalanceReplyDecision { + if (balanceReplyIsCurrent(asked, now)) return { kind: 'show' } + return asked.turn === now.turn + ? { kind: 'notice', text: WALLET_CHANGED_NOTICE } + : { kind: 'drop' } +} + +export function chainBalanceReply(balances: WalletBalances | undefined, chainName: string): string { + if (balances === undefined) return NOT_READ + const held = balances.tokens.filter((t) => t.native || !isZero(t.amount)) + const ordered = [ + ...held.filter((t) => t.native), + ...held.filter((t) => !t.native && t.listed), + ...held.filter((t) => !t.native && !t.listed), + ] + const shown = ordered.slice(0, MAX_LISTED).map(line) + const rest = ordered.length - shown.length + const parts = ['Your wallet holds:', ...shown] + if (rest > 0) parts.push(`and ${rest} more`) + if (!balances.complete) { + parts.push( + balances.knownOnly === true + ? `Only tokens Intent knows on ${chainName} are shown.` + : 'Some tokens could not be read.' + ) + } + return parts.join(String.fromCharCode(10)) +} diff --git a/apps/dapp/lib/chains/dollar-amount.ts b/apps/dapp/lib/chains/dollar-amount.ts new file mode 100644 index 00000000..c822cde9 --- /dev/null +++ b/apps/dapp/lib/chains/dollar-amount.ts @@ -0,0 +1,76 @@ +import type { ChainActionRequest } from './types' + +/** More places than any display needs, and few enough to fit most tokens. */ +const MOST_PLACES = 8 +const READ_TIMEOUT_MS = 8_000 + +/** Tokens for a sum in dollars, rounded down so it never spends more than was said. */ +export function unitsForDollars( + dollars: string, + priceUsd: number, + decimals: number +): string | undefined { + const sum = Number(dollars) + if (!Number.isFinite(sum) || sum <= 0 || !Number.isFinite(priceUsd) || priceUsd <= 0) { + return undefined + } + const places = Math.min(decimals, MOST_PLACES) + const scale = 10 ** places + const units = Math.floor((sum / priceUsd) * scale) / scale + if (units <= 0) return undefined + const text = units.toFixed(places) + // Past 1e21 this is exponent form, which is not an amount and would read as far fewer tokens. + if (!/^\d+(?:\.\d+)?$/.test(text)) return undefined + return text.includes('.') ? text.replace(/\.?0+$/, '') : text +} + +/** The sum as the person said it, the price used, and what that came to. */ +export function sizedLine( + dollars: string, + symbol: string, + priceUsd: number, + units: string +): string { + const price = priceUsd >= 1 ? priceUsd.toFixed(2) : priceUsd.toPrecision(4) + return `$${dollars} of ${symbol.toUpperCase()} at $${price} is ${units} ${symbol.toUpperCase()}.` +} + +type PriceRead = (url: string) => Promise<{ status: number; json: () => Promise }> + +const fromServer: PriceRead = (url) => fetch(url, { signal: AbortSignal.timeout(READ_TIMEOUT_MS) }) + +function noPrice(symbol: string): { refusal: string } { + return { + refusal: `I have no live price for ${symbol.toUpperCase()}, so I can't work out a dollar amount of it. Say how much ${symbol.toUpperCase()} instead. Nothing was signed or sent.`, + } +} + +/** + * A request in dollars as one in tokens, at the chain's own price read now, or why it can't be. + * This is the only way out of a dollar amount: with no price there is no guess, and no request. + */ +export async function sizeInUnits( + chain: string, + request: ChainActionRequest, + read: PriceRead = fromServer +): Promise<{ request: ChainActionRequest; line?: string } | { refusal: string }> { + const { amountIsUsd: _dollars, ...inTokens } = request + if (request.tokenIn.toUpperCase() === 'USDC') return { request: inTokens } + try { + const params = new URLSearchParams({ token: request.tokenIn }) + const response = await read(`/api/chain/${encodeURIComponent(chain)}/price?${params}`) + if (response.status !== 200) return noPrice(request.tokenIn) + const body = (await response.json()) as { priceUsd?: unknown; decimals?: unknown } + if (typeof body.priceUsd !== 'number' || typeof body.decimals !== 'number') { + return noPrice(request.tokenIn) + } + const units = unitsForDollars(request.amount, body.priceUsd, body.decimals) + if (units === undefined) return noPrice(request.tokenIn) + return { + request: { ...inTokens, amount: units }, + line: sizedLine(request.amount, request.tokenIn, body.priceUsd, units), + } + } catch { + return noPrice(request.tokenIn) + } +} diff --git a/apps/dapp/lib/chains/evm/balances.ts b/apps/dapp/lib/chains/evm/balances.ts new file mode 100644 index 00000000..a5800d1b --- /dev/null +++ b/apps/dapp/lib/chains/evm/balances.ts @@ -0,0 +1,61 @@ +import type { Address } from 'viem' + +import { fromBaseUnits } from '../solana/tokens' +import { + BALANCE_DEADLINE_MS, + ChainActionRefused, + withArticle, + type TokenBalance, + type WalletBalances, +} from '../types' +import { evmClient, type EvmClient } from './client' +import { EVM_CHAINS, EVM_NATIVE_ID, type EvmChain } from './tokens' + +const ADDRESS = /^0x[0-9a-fA-F]{40}$/ + +function untilAborted(work: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timedOut = (): void => reject(new Error('balance read timed out')) + if (signal.aborted) return timedOut() + signal.addEventListener('abort', timedOut, { once: true }) + work.then(resolve, reject) + }) +} + +/** + * The chain's coin and each token in its own table. There is no "everything held" call on an EVM + * chain, so this is never complete. + */ +export async function readEvmBalances( + chain: EvmChain, + account: string, + client: EvmClient = evmClient(chain), + signal: AbortSignal = AbortSignal.timeout(BALANCE_DEADLINE_MS) +): Promise { + if (!ADDRESS.test(account)) { + throw new ChainActionRefused( + `That is not ${withArticle(EVM_CHAINS[chain].name)} address. Connect an EVM wallet and try again.` + ) + } + const owner = account as Address + const tokens = await untilAborted( + Promise.all( + EVM_CHAINS[chain].tokens.map(async (token): Promise => { + const native = token.id === EVM_NATIVE_ID + const base = native + ? await client.nativeBalance(owner) + : await client.tokenBalance(token.id as Address, owner) + return { + symbol: token.symbol, + id: token.id, + amount: fromBaseUnits(base.toString(), token.decimals), + decimals: token.decimals, + native, + listed: true, + } + }) + ), + signal + ) + return { tokens, complete: false, knownOnly: true } +} diff --git a/apps/dapp/lib/chains/evm/capabilities.ts b/apps/dapp/lib/chains/evm/capabilities.ts index 1a5ef115..1c0cc5f1 100644 --- a/apps/dapp/lib/chains/evm/capabilities.ts +++ b/apps/dapp/lib/chains/evm/capabilities.ts @@ -2,9 +2,9 @@ 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' }, + { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'AVAX', then: ['cashout', 'send'] }, ] export const monadCapabilities: ChainCapability[] = [ - { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'MON' }, + { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'MON', then: ['send'] }, ] diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts index 8f45510c..1f843f16 100644 --- a/apps/dapp/lib/chains/evm/module.ts +++ b/apps/dapp/lib/chains/evm/module.ts @@ -1,13 +1,15 @@ import { oneClickPrice } from '../near/oneclick' import { canIssueOneClick, oneClickAssetId, oneClickOriginVenue } from '../oneclick-origin' +import { createSendVenue, SEND_VENUE_ID } from '../send' import type { ChainModule, ChainToken, ChainVenue } from '../types' +import { readEvmBalances } from './balances' import { EVM_CHAINS, type EvmChain } from './tokens' export function buildEvmVenues( chain: EvmChain, env: Record = process.env ): ChainVenue[] { - return canIssueOneClick(env) ? [oneClickOriginVenue(chain)] : [] + return canIssueOneClick(env) ? [oneClickOriginVenue(chain), createSendVenue(chain)] : [] } /** @@ -28,7 +30,7 @@ function evmModule(chain: EvmChain, tokenHints?: Record): ChainM chain, tokens: EVM_CHAINS[chain].tokens, venues: buildEvmVenues(chain), - defaultVenue: { swap: `1click-${chain}` }, + defaultVenue: { swap: `1click-${chain}`, send: SEND_VENUE_ID }, priceUsd: (token) => priceFromOneClick(chain, token), // Every venue here lands its own transactions, and no venue means no submit gets this far. submit: async () => ({ @@ -37,6 +39,7 @@ function evmModule(chain: EvmChain, tokenHints?: Record): ChainM error: "This app can't follow that from here. Check your wallet's activity before trying again.", }), + balances: (account, signal) => readEvmBalances(chain, account, undefined, signal), ...(tokenHints !== undefined ? { tokenHints } : {}), } } diff --git a/apps/dapp/lib/chains/explain.ts b/apps/dapp/lib/chains/explain.ts index a991368b..805905bd 100644 --- a/apps/dapp/lib/chains/explain.ts +++ b/apps/dapp/lib/chains/explain.ts @@ -1,15 +1,28 @@ +import { looksLikeChainAction } from './parse-action' import type { ChainActionKind } from './types' const EXAMPLE: Record string> = { swap: (symbol) => `swap 10 USDC to ${symbol}`, supply: (_symbol, venue) => `supply 25 USDC on ${venue}`, stake: (symbol, venue) => `stake 1 ${symbol} with ${venue}`, + send: (symbol) => `send 5 ${symbol} to an address`, +} + +/** What can come after a swap, in the words someone types. */ +export type SwapThen = 'cashout' | 'supply' | 'send' + +const THEN: Record = { + cashout: 'cash out', + supply: 'deposit', + send: 'send to an address', } export interface ChainCapability { kind: ChainActionKind venue: string symbol: string + /** On a swap: what can follow it on this chain, as one request. */ + then?: readonly SwapThen[] } /** What works on this chain today, said as instructions someone can copy. */ @@ -17,6 +30,36 @@ export function chainHelp(chainName: string, capabilities: readonly ChainCapabil if (capabilities.length === 0) { return `I can't do that on ${chainName} yet.` } - const examples = capabilities.map((c) => `"${EXAMPLE[c.kind](c.symbol, c.venue)}"`) + const examples = capabilities.flatMap((c) => [ + `"${EXAMPLE[c.kind](c.symbol, c.venue)}"`, + ...(c.then ?? []).map((next) => `"swap 5 ${c.symbol} to USDC then ${THEN[next]}"`), + ]) + // A plain send is read wherever a swap can end in one, though no capability lists it. + if ( + capabilities.some((c) => c.then?.includes('send')) && + !examples.some((e) => e.startsWith('"send ')) + ) { + examples.push(`"${EXAMPLE.send('USDC', '')}"`) + } return `I couldn't read that as something I can do on ${chainName}. Try ${examples.join(' or ')}.` } + +export const RATE_LIMITED = "You're sending messages quickly. Wait a minute and try again." + +const RATE_LIMITED_TODAY = "You've sent a lot of messages today. Try again later." + +/** + * What to say when the model did not read a message. Being held back by the rate limit says nothing + * about the words. A message that asks to do something is told what works. Nothing for any other, + * which gets the ordinary answer about the model being out of reach. + */ +export function helpForUnread( + reason: string, + said: readonly string[], + chainName: string, + capabilities: readonly ChainCapability[] +): string | undefined { + if (reason === 'rate_limited') return RATE_LIMITED + if (reason === 'rate_limited_today') return RATE_LIMITED_TODAY + return said.some(looksLikeChainAction) ? chainHelp(chainName, capabilities) : undefined +} diff --git a/apps/dapp/lib/chains/near/balances.ts b/apps/dapp/lib/chains/near/balances.ts new file mode 100644 index 00000000..b64bd66b --- /dev/null +++ b/apps/dapp/lib/chains/near/balances.ts @@ -0,0 +1,123 @@ +import { fromBaseUnits } from '../solana/tokens' +import { + BALANCE_DEADLINE_MS, + ChainActionRefused, + type TokenBalance, + type WalletBalances, +} from '../types' +import { NEAR_ACCOUNT } from './account' +import { accountBalance, viewCall } from './rpc' +import { nearTokens, WRAP_NEAR } from './tokens' + +const FASTNEAR = 'https://api.fastnear.com/v1/account' +const MAX_METADATA_READS = 20 +const NEAR_DECIMALS = 24 +const NATIVE_ID = 'near' + +interface FtHolding { + contract_id?: string + balance?: string +} + +async function fastNearList( + account: string, + signal: AbortSignal +): Promise<{ id: string; base: string }[]> { + const response = await fetch(`${FASTNEAR}/${encodeURIComponent(account)}/ft`, { signal }) + if (!response.ok) throw new Error(`FastNEAR answered ${response.status}`) + const body = (await response.json()) as { tokens?: FtHolding[] } + if (!Array.isArray(body.tokens)) throw new Error('FastNEAR list unreadable') + return body.tokens.map((t) => { + if (typeof t.contract_id !== 'string' || typeof t.balance !== 'string') { + throw new Error('FastNEAR holding unreadable') + } + return { id: t.contract_id, base: t.balance } + }) +} + +/** What the chain says the account holds of a table token, at finality final. A failed read throws. */ +async function tableEntry( + token: (typeof nearTokens)[number], + account: string, + signal: AbortSignal +): Promise { + const base = await viewCall(token.id, 'ft_balance_of', { account_id: account }, signal) + if (typeof base !== 'string' || !/^\d+$/.test(base)) throw new Error('ft balance unreadable') + return { + symbol: token.id === WRAP_NEAR ? 'wNEAR' : token.symbol, + id: token.id, + amount: fromBaseUnits(base, token.decimals), + decimals: token.decimals, + native: false, + listed: true, + } +} + +async function unlistedEntry( + id: string, + base: string, + signal: AbortSignal +): Promise { + try { + const meta = await viewCall<{ symbol?: unknown; decimals?: unknown }>( + id, + 'ft_metadata', + {}, + signal + ) + if (typeof meta.symbol !== 'string' || typeof meta.decimals !== 'number') return undefined + return { + symbol: meta.symbol, + id, + amount: fromBaseUnits(base, meta.decimals), + decimals: meta.decimals, + native: false, + listed: false, + } + } catch (e) { + if (signal.aborted) throw e + return undefined + } +} + +/** + * Table tokens are read from the chain, because the indexer behind FastNEAR lags and a token just + * delivered by a swap could be missing from its list. The list only discovers unlisted tokens, so + * one received in the last few seconds may be missing from those. + */ +export async function readNearBalances( + account: string, + signal: AbortSignal = AbortSignal.timeout(BALANCE_DEADLINE_MS) +): Promise { + if (!NEAR_ACCOUNT.test(account)) { + throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.') + } + const [nativeBase, table, discovered] = await Promise.all([ + accountBalance(account, signal), + Promise.all(nearTokens.map((token) => tableEntry(token, account, signal))), + fastNearList(account, signal).catch((e: unknown) => { + if (signal.aborted) throw e + return undefined + }), + ]) + const native: TokenBalance = { + symbol: 'NEAR', + id: NATIVE_ID, + amount: fromBaseUnits(nativeBase, NEAR_DECIMALS), + decimals: NEAR_DECIMALS, + native: true, + listed: true, + } + if (discovered === undefined) return { tokens: [native, ...table], complete: false } + + const unlisted = discovered.filter( + (h) => !nearTokens.some((t) => t.id === h.id) && !/^0+$/.test(h.base) + ) + const read = unlisted.slice(0, MAX_METADATA_READS) + const entries = await Promise.all(read.map((h) => unlistedEntry(h.id, h.base, signal))) + const found = entries.flatMap((e) => (e === undefined ? [] : [e])) + return { + tokens: [native, ...table, ...found], + complete: unlisted.length === found.length, + } +} diff --git a/apps/dapp/lib/chains/near/capabilities.ts b/apps/dapp/lib/chains/near/capabilities.ts index 607ba4ed..d262f17f 100644 --- a/apps/dapp/lib/chains/near/capabilities.ts +++ b/apps/dapp/lib/chains/near/capabilities.ts @@ -2,7 +2,7 @@ import type { ChainCapability } from '../explain' /** What actually works here, for telling someone who asked for something else. Client-safe. */ export const nearCapabilities: ChainCapability[] = [ - { kind: 'swap', venue: 'NEAR Intents', symbol: 'NEAR' }, + { kind: 'swap', venue: 'NEAR Intents', symbol: 'NEAR', then: ['supply', 'send'] }, { kind: 'stake', venue: 'Meta Pool', symbol: 'NEAR' }, { kind: 'stake', venue: 'rNEAR', symbol: 'NEAR' }, { kind: 'supply', venue: '1Click Earn', symbol: 'USDC' }, diff --git a/apps/dapp/lib/chains/near/earn.ts b/apps/dapp/lib/chains/near/earn.ts index 11aa5fa6..0bf06e09 100644 --- a/apps/dapp/lib/chains/near/earn.ts +++ b/apps/dapp/lib/chains/near/earn.ts @@ -7,6 +7,7 @@ import { type PreparedAction, type SigningPayload, type VenueQuote, + takesOnly, } from '../types' import { assertOneClickSafe } from './safety' import { prepareOneClickDeposit, quoteOneClick, submitOneClick } from './swap' @@ -84,6 +85,7 @@ export const earnVenue: ChainVenue = { id: EARN_VENUE_ID, name: VENUE_NAME, kinds: ['supply'], + assertCanFollow: takesOnly(VENUE_NAME, ['USDC', 'USDT']), quote: quoteEarn, prepare: prepareEarn, assertSafe: assertEarnSafe, diff --git a/apps/dapp/lib/chains/near/module.ts b/apps/dapp/lib/chains/near/module.ts index 0ad0bc51..672c3dc3 100644 --- a/apps/dapp/lib/chains/near/module.ts +++ b/apps/dapp/lib/chains/near/module.ts @@ -1,14 +1,29 @@ -import type { ChainModule } from '../types' +import { canIssueOneClick } from '../oneclick-origin' +import { createSendVenue, SEND_VENUE_ID } from '../send' +import type { ChainModule, ChainVenue } from '../types' +import { readNearBalances } from './balances' import { EARN_VENUE_ID, earnVenue } from './earn' import { assetIdOf, oneClickPrice } from './oneclick' import { confirmStake, metapoolVenue, METAPOOL_VENUE_ID, rnearVenue } from './stake' import { ONECLICK_VENUE_ID, oneClickVenue } from './swap' import { nearTokens } from './tokens' +export function buildNearVenues( + env: Record = process.env +): ChainVenue[] { + return [ + oneClickVenue, + metapoolVenue, + rnearVenue, + earnVenue, + ...(canIssueOneClick(env) ? [createSendVenue('near')] : []), + ] +} + export const nearModule: ChainModule = { chain: 'near', tokens: nearTokens, - venues: [oneClickVenue, metapoolVenue, rnearVenue, earnVenue], + venues: buildNearVenues(), defaultVenue: { swap: ONECLICK_VENUE_ID, supply: EARN_VENUE_ID, @@ -18,7 +33,9 @@ export const nearModule: ChainModule = { * 404. Its payload is also simpler, and it doesn't lean on Rhea Lend. */ stake: METAPOOL_VENUE_ID, + send: SEND_VENUE_ID, }, priceUsd: (token) => oneClickPrice(assetIdOf(token), token.decimals), submit: confirmStake, + balances: readNearBalances, } diff --git a/apps/dapp/lib/chains/near/rpc.ts b/apps/dapp/lib/chains/near/rpc.ts index e713fe46..fedc1320 100644 --- a/apps/dapp/lib/chains/near/rpc.ts +++ b/apps/dapp/lib/chains/near/rpc.ts @@ -10,12 +10,20 @@ function rpcUrl(): string { return configured === undefined || configured === '' ? DEFAULT_RPC_URL : configured } -async function rpcCall(method: string, params: unknown, timeoutMs: number): Promise { +async function rpcCall( + method: string, + params: unknown, + timeoutMs: number, + signal?: AbortSignal +): Promise { const response = await fetch(rpcUrl(), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 'intent', method, params }), - signal: AbortSignal.timeout(timeoutMs), + signal: + signal === undefined + ? AbortSignal.timeout(timeoutMs) + : AbortSignal.any([AbortSignal.timeout(timeoutMs), signal]), }) if (!response.ok) throw new NearRpcError(`rpc responded ${response.status}`) const body = (await response.json()) as { @@ -32,7 +40,8 @@ async function rpcCall(method: string, params: unknown, timeoutMs: number): Prom export async function viewCall( contract: string, method: string, - args: Record = {} + args: Record = {}, + signal?: AbortSignal ): Promise { const result = (await rpcCall( 'query', @@ -43,7 +52,8 @@ export async function viewCall( method_name: method, args_base64: Buffer.from(JSON.stringify(args)).toString('base64'), }, - VIEW_TIMEOUT_MS + VIEW_TIMEOUT_MS, + signal )) as { result?: number[]; error?: string } if (!Array.isArray(result.result)) { throw new NearRpcError(result.error ?? 'view call returned nothing') @@ -157,3 +167,15 @@ export async function waitForTransaction( } return { state: 'unknown' } } + +/** Yocto of NEAR an account holds in total, storage included, read at final. */ +export async function accountBalance(account: string, signal?: AbortSignal): Promise { + const result = (await rpcCall( + 'query', + { request_type: 'view_account', finality: 'final', account_id: account }, + VIEW_TIMEOUT_MS, + signal + )) as { amount?: unknown } + if (typeof result.amount !== 'string') throw new NearRpcError('account balance unreadable') + return result.amount +} diff --git a/apps/dapp/lib/chains/near/safety.ts b/apps/dapp/lib/chains/near/safety.ts index c1ea7bcd..ff7b6b38 100644 --- a/apps/dapp/lib/chains/near/safety.ts +++ b/apps/dapp/lib/chains/near/safety.ts @@ -146,6 +146,18 @@ function assertStorageDeposit(call: Call, allowedAccounts: readonly string[]): v * and any storage payment are reverted and refunded too. */ export function assertOneClickSafe(payload: SigningPayload, account: string): void { + assertTokenTransferSafe(payload, account, DEPOSIT_ADDRESS) +} + +/** + * The same check, with the shape the transfer's receiver must have. Only for a caller that binds + * the exact receiver itself afterwards, as a send does. + */ +export function assertTokenTransferSafe( + payload: SigningPayload, + account: string, + receiver: RegExp +): void { const transaction = readSingleTransaction(payload, account) if (!SWAP_CONTRACTS.has(transaction.receiverId)) { refuse(`${transaction.receiverId} is not a token this app trades`) @@ -162,7 +174,7 @@ export function assertOneClickSafe(payload: SigningPayload, account: string): vo } const depositAddress = transfer.args['receiver_id'] const amountText = transfer.args['amount'] - if (typeof depositAddress !== 'string' || !DEPOSIT_ADDRESS.test(depositAddress)) { + if (typeof depositAddress !== 'string' || !receiver.test(depositAddress)) { refuse('the token transfer does not go to a deposit address') } if (depositAddress === account) refuse('the token transfer pays your own account') diff --git a/apps/dapp/lib/chains/near/stake.ts b/apps/dapp/lib/chains/near/stake.ts index 87e67e25..93819f55 100644 --- a/apps/dapp/lib/chains/near/stake.ts +++ b/apps/dapp/lib/chains/near/stake.ts @@ -9,6 +9,7 @@ import { type SubmitInput, type SubmitResult, type VenueQuote, + type FollowOnStep, } from '../types' import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account' import { isRegistered, viewCall, waitForTransaction } from './rpc' @@ -37,6 +38,16 @@ const RHEA_APY_URL = 'https://api.rhea.finance/get-rnear-apy' const APY_TIMEOUT_MS = 5_000 const CONFIRM_WAIT_MS = 25_000 +/** + * A swap to NEAR can arrive as NEAR or as wNEAR, and only the wrapped form can be measured by its + * token, while a stake spends the native coin. So a stake can't be shown to spend what arrived. + */ +async function refuseStakeAfterSwap(_step: FollowOnStep): Promise { + throw new ChainActionRefused( + "A swap to NEAR can arrive as NEAR or wNEAR, so a stake can't follow it here. Swap first, then stake what arrives. Nothing was signed or sent." + ) +} + function cannotRead(venue: string): ChainActionRefused { return new ChainActionRefused( `${venue} could not be read from NEAR just now, so nothing was built. Try again in a minute.` @@ -223,6 +234,7 @@ export const rnearVenue: ChainVenue = { id: RNEAR_VENUE_ID, name: 'rNEAR', kinds: ['stake'], + assertCanFollow: refuseStakeAfterSwap, quote: quoteRnear, prepare: prepareRnear, assertSafe: assertRnearSafe, @@ -232,6 +244,7 @@ export const metapoolVenue: ChainVenue = { id: METAPOOL_VENUE_ID, name: 'Meta Pool', kinds: ['stake'], + assertCanFollow: refuseStakeAfterSwap, quote: quoteMetapool, prepare: prepareMetapool, assertSafe: assertMetapoolSafe, diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts index 23295f94..94ad3b84 100644 --- a/apps/dapp/lib/chains/oneclick-origin.ts +++ b/apps/dapp/lib/chains/oneclick-origin.ts @@ -216,7 +216,8 @@ export async function oneClickAssetId( return matching.length === 1 ? matching[0]?.assetId : undefined } -function secret(env: Record = process.env): string | undefined { +/** The key proofs are signed with, or nothing where this deployment has none long enough. */ +export function secret(env: Record = process.env): string | undefined { const value = env['AUTH_SECRET'] return value !== undefined && value.length >= 32 ? value : undefined } @@ -262,7 +263,7 @@ function proofOf(venueId: string, account: string, issued: Issued, key: string): return createHmac('sha256', key).update(`oneclick-origin:${message}`).digest('base64url') } -function sameProof(given: string, expected: string): boolean { +export 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) diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index fac32b39..f7c9088a 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -1,6 +1,11 @@ import type { ChainActionRequest } from './types' -const AMOUNT = String.raw`\$?([\d,]*\.?\d+)` +/** + * Commas only as thousands, 1,000 or 1,000.5. In "1,5" or "0,500" a comma is a decimal point, and + * dropping it would read many times the amount, so that is no amount at all and the person is told + * what works. Thousands never start with a zero. + */ +export const AMOUNT = String.raw`\$?((?:[1-9]\d{0,2}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)` const TOKEN = String.raw`([a-z][a-z0-9.+]{0,11})` const VENUE = String.raw`(?:\s+(?:on|via|with|using|in|into|at|to)\s+([a-z0-9][a-z0-9 .-]{1,30}?))?` const END = String.raw`\s*[.!]?\s*$` @@ -17,15 +22,48 @@ const STAKE = new RegExp( String.raw`^(?:please\s+)?(?:liquid\s+)?stake\s+${AMOUNT}\s*${TOKEN}${VENUE}${END}`, 'i' ) +/** Case stays as typed, since it is part of an address. Ends on a letter or digit, not a full stop. */ +const SEND = new RegExp( + String.raw`^(?:please\s+)?(?:send|transfer)\s+${AMOUNT}\s*${TOKEN}\s+to\s+([a-z0-9](?:[a-z0-9._:-]*[a-z0-9])?)${END}`, + 'i' +) /** Sentences that ask to do something, so a miss gets told what works rather than a guess. */ 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 + /\b(swap|convert|exchange|trade|change|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i + +/** People say the chain for its coin. `sui` and `near` are already their symbols. */ +const TOKEN_OF_CHAIN_NAME: ReadonlyMap = new Map([ + ['solana', 'SOL'], + ['avalanche', 'AVAX'], + ['monad', 'MON'], +]) + +/** A chain's name as its token, whole word and any case. Every other word comes back unchanged. */ +export function chainTokenWord(word: string): string { + return TOKEN_OF_CHAIN_NAME.get(word.toLowerCase()) ?? word +} + +/** The chain names that read as this token, for checking what someone said against it. */ +export function chainNamesOf(token: string): string[] { + const wanted = token.toUpperCase() + return [...TOKEN_OF_CHAIN_NAME].filter(([, symbol]) => symbol === wanted).map(([name]) => name) +} -/** Plain decimal text: no separators, and a leading zero on ".5", which nothing downstream reads. */ -function amountOf(text: string): string { +/** Plain decimal text: no separators, one leading zero on ".5" and none on "05". */ +export function amountOf(text: string): string { const plain = text.replace(/,/g, '') - return plain.startsWith('.') ? `0${plain}` : plain + return (plain.startsWith('.') ? `0${plain}` : plain).replace(/^0+(?=\d)/, '') +} + +/** + * "$5 SOL" is five dollars of SOL, not five SOL, which differ by the price of SOL. A dollar of USDC + * is one USDC, so that stays a count. The `$` must come before the first figure: it is the amount's. + */ +export function dollarsOn(text: string, token: string): { amountIsUsd?: true } { + return /^[^\d$]*\$/.test(text.trim()) && token.toUpperCase() !== 'USDC' + ? { amountIsUsd: true } + : {} } /** @@ -38,35 +76,57 @@ function amountOf(text: string): string { export function parseChainAction(text: string): ChainActionRequest | null { const swap = SWAP.exec(text.trim()) if (swap !== null) { + const tokenIn = chainTokenWord(swap[2] as string) return { kind: 'swap', amount: amountOf(swap[1] as string), - tokenIn: swap[2] as string, - tokenOut: swap[3] as string, + ...dollarsOn(text, tokenIn), + tokenIn, + tokenOut: chainTokenWord(swap[3] as string), venue: swap[4]?.trim(), } } const supply = SUPPLY.exec(text.trim()) if (supply !== null) { + const tokenIn = chainTokenWord(supply[2] as string) return { kind: 'supply', amount: amountOf(supply[1] as string), - tokenIn: supply[2] as string, + ...dollarsOn(text, tokenIn), + tokenIn, venue: supply[3]?.trim(), } } const stake = STAKE.exec(text.trim()) if (stake !== null) { + const tokenIn = chainTokenWord(stake[2] as string) return { kind: 'stake', amount: amountOf(stake[1] as string), - tokenIn: stake[2] as string, + ...dollarsOn(text, tokenIn), + tokenIn, venue: stake[3]?.trim(), } } + const send = SEND.exec(text.trim()) + if (send !== null) { + const tokenIn = chainTokenWord(send[2] as string) + return { + kind: 'send', + amount: amountOf(send[1] as string), + ...dollarsOn(text, tokenIn), + tokenIn, + recipient: send[3] as string, + } + } return null } +/** Whether the agents price this first. A send has no price to race on and nothing to choose. */ +export function racesFirst(request: Pick): boolean { + return request.kind !== 'send' +} + export function looksLikeChainAction(text: string): boolean { return ACTION_VERB.test(text) } diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts new file mode 100644 index 00000000..fc57e860 --- /dev/null +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -0,0 +1,477 @@ +import { + currencyNamedIn, + isFiatWord, + parseCashout, + routeCashout, + type CashoutRoute, +} from '../weave/parse-cashout' +import { AMOUNT, amountOf, chainTokenWord, dollarsOn, parseChainAction } from './parse-action' +import { + SEQUENCE_CASHOUT_CHAINS, + followOnRefusal, + type ChainSequenceRequest, + type FollowOn, +} from './sequence' +import { dollarsSaid, sequenceWordsMatch, swapOrderSaid } from './sequence-flow' +import type { ChainActionKind, ChainActionRequest, ChainWithActions } from './types' + +const TOKEN = String.raw`([a-z][a-z0-9.+]{0,11})` +const CONNECTOR = String.raw`(?:\s*,\s*(?:and\s+then\s+|then\s+|and\s+)?|\s+(?:and\s+then|then|and)\s+)` + +const SEQUENCE = new RegExp( + String.raw`^(?:please\s+)?(?:swap|convert|exchange|trade|change)\s+${AMOUNT}\s*${TOKEN}\s+(?:to|for|into)\s+${TOKEN}${CONNECTOR}(.+?)\s*[.!]?\s*$`, + 'i' +) +const CASHOUT = /^(?:cash[ -]?out|off[ -]?ramp|withdraw to bank)(?:\s+(.+))?$/i +const EARN = + /^(deposit|supply|lend|save|earn)(?:\s+it)?(?:\s+(?:on|via|with|using|in|into|at|to)\s+(\S.*?))?$/i +const STAKE = /^stake(?:\s+it)?(?:\s+(?:on|via|with|using|in|into|at|to)\s+(\S.*?))?$/i +/** An address and nothing else: NEAR accounts add `.`, `_`, `-` and `:` inside. */ +const ADDRESS_CHARS = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ +const SEND = /^(?:send|transfer)(?:\s+it)?\s+to\s+(\S+)$/i + +function followOnOf(text: string): FollowOn | { refusal: string } | null { + const cashout = CASHOUT.exec(text) + if (cashout !== null) { + if (cashout[1] === undefined) return { kind: 'cashout' } + const read = parseCashout(`cash out ${cashout[1]}`) + if (read === null || 'unsupportedToken' in read) return null + if ('unsupportedCurrency' in read) { + return { + refusal: `Cash-outs don't pay out in ${read.unsupportedCurrency}. Nothing was signed or sent.`, + } + } + if (read.amount !== undefined) return null + return read.currency === undefined + ? { kind: 'cashout' } + : { kind: 'cashout', currency: read.currency } + } + const earn = EARN.exec(text) + if (earn !== null) + return earn[2] === undefined ? { kind: 'supply' } : { kind: 'supply', venue: earn[2].trim() } + const stake = STAKE.exec(text) + if (stake !== null) + return stake[1] === undefined ? { kind: 'stake' } : { kind: 'stake', venue: stake[1].trim() } + const send = SEND.exec(text) + if (send !== null && ADDRESS_CHARS.test(send[1] as string)) { + return { kind: 'send', recipient: send[1] as string } + } + return null +} + +/** + * Reads "swap X to Y, then ", or nothing. + * A strict shape, like a plain action. A cash-out after a swap that does not end in USDC + * is refused in words, since that is never going to work. + */ +export function parseChainSequence( + text: string +): ChainSequenceRequest | { refusal: string } | null { + const match = SEQUENCE.exec(text.trim()) + if (match === null) return null + const followOn = followOnOf((match[4] as string).trim()) + if (followOn === null) return null + if ('refusal' in followOn) return followOn + const tokenIn = chainTokenWord(match[2] as string) + return withSwap( + { + kind: 'swap', + amount: amountOf(match[1] as string), + ...dollarsOn(text, tokenIn), + tokenIn, + tokenOut: chainTokenWord(match[3] as string), + venue: undefined, + }, + followOn + ) +} + +/** A cash-out after a swap that does not end in USDC is refused in words, since it cannot work. */ +function withSwap( + swap: ChainActionRequest, + followOn: FollowOn +): ChainSequenceRequest | { refusal: string } { + const tokenOut = swap.tokenOut as string + if (followOn.kind === 'cashout' && tokenOut.toUpperCase() !== 'USDC') { + return { + refusal: `Cash-outs take USDC, so this swap would have to end in USDC, not ${tokenOut.toUpperCase()}. Nothing was signed or sent.`, + } + } + return { swap, followOn } +} + +const SWAP_INTO_WORDS = new RegExp( + String.raw`^(?:please\s+)?(?:swap|convert|exchange|trade|change)\s+(?:my\s+)?(${AMOUNT})\s*${TOKEN}\s+(?:to|for|into)\s+(?:my\s+|the\s+)?(.+?)\s*[.!]?\s*$`, + 'i' +) + +type CashoutGoes = Exclude + +/** What a message typed on a chain with its own module is, before any model has read it. */ +export type ChainMessageRead = + | { kind: 'refusal'; refusal: string } + | { kind: 'sequence'; request: ChainSequenceRequest } + | { kind: 'action'; action: ChainActionRequest } + | { kind: 'cashout'; route: CashoutGoes } + | { kind: 'model' } + +/** + * "change 5 SOL into naira": the output is money and not a token, so this is a cash-out. Nothing + * for a swap that ends in anything else. A venue after the currency has no bearing on a payout. + */ +function swapIntoCurrency( + text: string, + chain: ChainWithActions, + chainName: string +): ChainMessageRead | undefined { + const match = SWAP_INTO_WORDS.exec(text.trim()) + if (match === null) return undefined + const target = (match[4] as string) + .toLowerCase() + .replace(/\s+(?:on|via|with|using|at)\s.*$/, '') + .replace(/\s+/g, ' ') + if (!isFiatWord(target)) return undefined + if (!SEQUENCE_CASHOUT_CHAINS.includes(chain)) { + const refusal = followOnRefusal(chain, chainName, { kind: 'cashout' }, []) + return refusal === undefined ? { kind: 'model' } : { kind: 'refusal', refusal } + } + const route = routeCashout( + parseCashout(`cash out ${match[1] as string} ${match[3] as string} to ${target}`), + chain + ) + return route.kind === 'pass' ? { kind: 'model' } : { kind: 'cashout', route } +} + +/** + * This decides whether a message off Stellar goes to the model. Only a request the strict parsers + * read in full stays here. Everything else does go, verbs included, since the words people use + * have no end: `chainRouteFromReading` holds what comes back to the person's own words, and returns + * nothing when nothing can run it, and `helpForUnread` answers if the model can't be reached. + */ +export function readChainMessage( + text: string, + chain: ChainWithActions, + chainName: string +): ChainMessageRead { + const sequence = parseChainSequence(text) + if (sequence !== null) { + return 'refusal' in sequence + ? { kind: 'refusal', refusal: sequence.refusal } + : { kind: 'sequence', request: sequence } + } + const currency = swapIntoCurrency(text, chain, chainName) + if (currency !== undefined) return currency + const action = parseChainAction(text) + return action === null ? { kind: 'model' } : { kind: 'action', action } +} + +/** Whether a cash-out route opens something, where the others only answer in words. */ +export function cashoutStarts(route: CashoutRoute): boolean { + return route.kind === 'ask' || route.kind === 'open' || route.kind === 'sequence' +} + +/** + * Whether a typed message starts an action, for `keepsRun`. A refusal starts nothing, and what the + * model has yet to read is not known to, so neither ends what is under way. + */ +export function messageStartsAction(route: CashoutRoute, read: ChainMessageRead | null): boolean { + if (cashoutStarts(route)) return true + if (read === null) return false + if (read.kind === 'cashout') return cashoutStarts(read.route) + return read.kind === 'sequence' || read.kind === 'action' +} + +/** + * What a model read of a request, as far as this file needs it. The orchestrator's reading fits it, + * and so does the chat's `UnderstoodIntent`. + */ +export interface ReadSwap { + tokenIn: string + tokenOut: string + amountUsd: number + amountStated: boolean + amountIsUsd?: boolean | undefined + amountOf?: 'sent' | 'received' | undefined + followOn: { kind: string; venue: string; venueNamed?: true; recipient?: string } | null + /** A follow-on the server read and this chain can't honour. It must not become a plain swap. */ + followOnRefused?: { kind: 'lend' | 'offramp'; venue: string } | undefined +} + +const EXAMPLE_AFTER: Record = { + offramp: ' then cash out', + lend: ' then deposit', + stake: ' then stake', + send: ' then send to an address', +} + +/** An example that could run: USDC can't be swapped to itself, and goes to a bank with no swap. */ +function exampleOf(read: ReadSwap, asked: ChainActionKind | undefined): string { + const kind = read.followOn?.kind + const after = kind === undefined ? '' : (EXAMPLE_AFTER[kind] ?? '') + const tokenIn = chainTokenWord(read.tokenIn) + if (tokenIn.toUpperCase() !== 'USDC') return `swap 5 ${tokenIn} to USDC${after}` + if (kind === 'offramp') return 'cash out 5 USDC' + const tokenOut = chainTokenWord(read.tokenOut) + if (tokenOut.toUpperCase() !== 'USDC') return `swap 5 USDC to ${tokenOut}${after}` + if (kind === 'send' || asked === 'send') return 'send 5 USDC to an address' + if (kind === 'stake' || asked === 'stake') return 'stake 5 USDC' + return 'deposit 5 USDC' +} + +function refuseUnsaid( + read: ReadSwap, + what: string, + /** What was asked, where the reading alone doesn't say. */ + asked?: ChainActionKind +): { refusal: string } { + return { + refusal: `I could not find ${what} in what you typed, and only your own words decide what is signed. Say it in full, for example "${exampleOf(read, asked)}". Nothing was signed or sent.`, + } +} + +/** The amount and token a model read, or why it cannot be used. Nothing is checked against words. */ +function swapOf(read: ReadSwap): ChainActionRequest | { refusal: string } { + const amount = String(read.amountUsd) + if (!read.amountStated || read.amountOf === 'received' || !/^\d+(?:\.\d+)?$/.test(amount)) { + return refuseUnsaid(read, 'an amount of the token you are swapping') + } + return { + kind: 'swap', + amount, + ...(read.amountIsUsd === true ? { amountIsUsd: true as const } : {}), + tokenIn: chainTokenWord(read.tokenIn), + tokenOut: chainTokenWord(read.tokenOut), + venue: undefined, + } +} + +/** A venue's id, such as jupiter-lend, against "Jupiter Lend" or "jupiterlend" as typed. */ +function venueSaid(venue: string, said: readonly string[]): boolean { + const plain = (text: string): string => text.toLowerCase().replace(/[^a-z0-9]/g, '') + return said.some((turn) => plain(turn).includes(plain(venue))) +} + +/** An address or name exactly as typed: whole, with the same letters, not inside a longer word. */ +function typedExactly(recipient: string, said: readonly string[]): boolean { + if (recipient === '') return false + const escaped = recipient.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const whole = new RegExp( + String.raw`(? whole.test(turn)) +} + +/** + * Whether the amount, the token and any recipient of a request were in what the person typed. A + * model writes none of these into a signature: it can only restate words that are there. + */ +export function requestSaid( + request: ChainActionRequest, + said: readonly string[], + /** A cash-out follows, which takes USDC, so a swap that names no output is to USDC. */ + usdcImplied = false +): boolean { + const out = request.tokenOut + if (request.amountIsUsd === true) { + const asNamed = dollarsSaid(request.amount, request.tokenIn, out, said) + const toUsdc = + usdcImplied && + out !== undefined && + chainTokenWord(out).toUpperCase() === 'USDC' && + dollarsSaid(request.amount, request.tokenIn, undefined, said) + if (!asNamed && !toUsdc) return false + return request.recipient === undefined || typedExactly(request.recipient, said) + } + if (!sequenceWordsMatch({ amount: request.amount, token: request.tokenIn }, said)) return false + // Named in order: the amount by the token given up, and the token received after it. A cash-out + // takes USDC, so with one following, a swap that names no output is to USDC. + const named = out !== undefined && swapOrderSaid(request.amount, request.tokenIn, out, said) + const implied = + out === undefined || + (usdcImplied && + chainTokenWord(out).toUpperCase() === 'USDC' && + swapOrderSaid(request.amount, request.tokenIn, undefined, said)) + if (out !== undefined && !named && !implied) return false + if (out === undefined && !swapOrderSaid(request.amount, request.tokenIn, undefined, said)) { + return false + } + return request.recipient === undefined || typedExactly(request.recipient, said) +} + +/** + * A swap a model read with a follow-on, as the same sequence a typed sentence makes, or nothing + * when it read no follow-on. The amount and token must be in `said`, the person's own typed turns, + * and so must a recipient, character for character. What fails is refused in words, never run. + */ +export function sequenceFromReading( + read: ReadSwap, + said: readonly string[] +): ChainSequenceRequest | { refusal: string } | null { + const model = read.followOn ?? refusedFollowOn(read, said) + if (model === null) return null + const swap = swapOf(read) + if ('refusal' in swap) return swap + if (!requestSaid(swap, said, model.kind === 'offramp')) { + return refuseUnsaid(read, 'that amount, token and token to receive') + } + + let followOn: FollowOn + if (model.kind === 'offramp') { + if (model.venueNamed === true) { + return { + refusal: `Cash-outs here don't run through ${model.venue}. Nothing was signed or sent.`, + } + } + followOn = withCurrency({ kind: 'cashout' }, said) + } else if (model.kind === 'lend') { + if (model.venueNamed === true && !venueSaid(model.venue, said)) { + return refuseUnsaid(read, 'that venue') + } + followOn = + model.venueNamed === true ? { kind: 'supply', venue: model.venue } : { kind: 'supply' } + } else if (model.kind === 'send') { + const recipient = model.recipient ?? '' + if (!typedExactly(recipient, said)) return refuseUnsaid(read, 'that address') + followOn = { kind: 'send', recipient } + } else { + return refuseUnsaid(read, 'what to do after the swap') + } + return withSwap(swap, followOn) +} + +/** + * A follow-on the server refused, as one the chat can refuse in the chain's own words before step + * 1. The venue stays only if the person named it, for the server to answer. One the model chose is + * dropped, like any other. + */ +function refusedFollowOn(read: ReadSwap, said: readonly string[]): ReadSwap['followOn'] { + const refused = read.followOnRefused + if (refused === undefined) return null + return refused.venue !== '' && venueSaid(refused.venue, said) + ? { kind: refused.kind, venue: refused.venue, venueNamed: true } + : { kind: refused.kind, venue: '' } +} + +/** A cash-out with the currency the person named, so they are not asked it again. */ +function withCurrency( + cashout: Extract, + said: readonly string[] +): FollowOn { + if (cashout.currency !== undefined) return cashout + const currency = currencyNamedIn(said) + return currency === undefined ? cashout : { ...cashout, currency } +} + +/** What a sentence the model wrote may set for step 2: never a venue the person didn't name. */ +function ownFollowOn(followOn: FollowOn, said: readonly string[]): FollowOn { + if (followOn.kind === 'cashout') return withCurrency(followOn, said) + if (followOn.kind === 'send' || followOn.venue === undefined) return followOn + return venueSaid(followOn.venue, said) ? followOn : { kind: followOn.kind } +} + +const FOLLOW_ON_WORDS: readonly (readonly [RegExp, string])[] = [ + [/\b(?:cash[ -]?out|off[ -]?ramp|withdraw)\b/i, 'offramp'], + [/\b(?:deposit|supply|lend|save|earn)\b/i, 'lend'], + [/\bstake\b/i, 'stake'], + [/\b(?:send|transfer)\b/i, 'send'], +] + +/** The follow-on these words ask for, in a reading's name for it, or nothing if they name none. */ +function followOnKindSaid(instruction: string, said: readonly string[]): string | undefined { + const text = [...said, instruction].join(' ') + return FOLLOW_ON_WORDS.find(([words]) => words.test(text))?.[1] +} + +/** Something was asked after the swap that no reading here holds, so a plain swap would lose it. */ +function moreAfterSwap(instruction: string, said: readonly string[]): boolean { + return SEQUENCE.test(instruction.trim()) || said.some((turn) => /\bthen\s+\S/i.test(turn)) +} + +/** A plain swap a model read, held to the same words as a sequence. */ +export function swapFromReading( + read: ReadSwap, + said: readonly string[] +): ChainActionRequest | { refusal: string } { + const swap = swapOf(read) + if ('refusal' in swap) return swap + return requestSaid(swap, said) + ? swap + : refuseUnsaid({ ...read, followOn: null }, 'that amount, token and token to receive') +} + +/** A reading's name for each follow-on, which is what the examples go by. */ +const READING_KIND: Record = { + cashout: 'offramp', + supply: 'lend', + stake: 'stake', + send: 'send', +} + +/** What the chat does with an act the orchestrator read off Stellar. */ +export type ChainRoute = + | { sequence: ChainSequenceRequest } + | { action: ChainActionRequest } + | { refusal: string } + +/** + * Where an orchestrator reading goes off Stellar, so it never reaches Stellar's swap card. + * + * A sentence the model wrote out in the shape a person types, "swap 5 SOL to USDC, then send to X", + * is read by the same strict parsers as one typed. A follow-on it set in its reading is converted by + * `sequenceFromReading`, and so is one the server could not honour, which is then refused before + * step 1 like any other that can't run. Either way, the amount, the token and any recipient must be + * in `said`, the person's own turns, or the request is refused in words. Nothing when nothing here + * can run it. + */ +export function chainRouteFromReading( + read: ReadSwap & { action: string }, + instruction: string, + said: readonly string[] +): ChainRoute | null { + if (read.followOn !== null || read.followOnRefused !== undefined) { + const out = sequenceFromReading(read, said) + return out === null ? null : 'refusal' in out ? out : { sequence: out } + } + const sequence = parseChainSequence(instruction) + if (sequence !== null) { + if ('refusal' in sequence) return sequence + const { followOn } = sequence + const recipientSaid = followOn.kind !== 'send' || typedExactly(followOn.recipient, said) + return requestSaid(sequence.swap, said, followOn.kind === 'cashout') && recipientSaid + ? { sequence: { ...sequence, followOn: ownFollowOn(followOn, said) } } + : refuseUnsaid( + { ...read, followOn: { kind: READING_KIND[followOn.kind], venue: '' } }, + 'that request' + ) + } + const action = parseChainAction(instruction) + if (action === null && read.action !== 'swap') return null + if (moreAfterSwap(instruction, said)) { + const kind = followOnKindSaid(instruction, said) + return refuseUnsaid( + { ...read, followOn: kind === undefined ? null : { kind, venue: '' } }, + 'what to do after the swap' + ) + } + if (action !== null) { + // A venue the model added is dropped, and the action kept, unless the person named it. + const own = + action.venue !== undefined && !venueSaid(action.venue, said) + ? { ...action, venue: undefined } + : action + return requestSaid(own, said) + ? { action: own } + : refuseUnsaid( + { + ...read, + tokenIn: action.tokenIn, + tokenOut: action.tokenOut ?? action.tokenIn, + followOn: null, + }, + 'that request', + action.kind + ) + } + const swap = swapFromReading(read, said) + return 'refusal' in swap ? swap : { action: swap } +} diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index f78877a0..814e6be8 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -1,14 +1,21 @@ import { CHAIN_DESCRIPTORS } from '@intent/config' import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade-cap' +import { WRAP_NEAR } from './near/tokens' +import { sendRefusal } from './recipient' +import { fromBase, toBase } from './sequence' +import { holdingOf } from './sequence-flow' import { ChainActionRefused, tokenBySymbol, type ChainActionRequest, type ChainModule, type ChainToken, + type ChainWithActions, type ChainVenue, + type FollowOnStep, type PreparedAction, + type WalletBalances, } from './types' /** Keys only: each chain's name is its slug, and reading Stellar's descriptor at load would throw. */ @@ -113,18 +120,60 @@ export async function assertAmountWithinCap( } // The cap's own wording is for whoever runs the deployment, so a person gets this instead. const usd = price === undefined ? Number.NaN : amount * price + const noun = request.kind === 'send' ? 'transfer' : 'trade' try { assertWithinCap(usd, env, 'mainnet') } catch (e) { if (!(e instanceof TradeCapExceeded)) throw e throw new ChainActionRefused( Number.isFinite(usd) - ? `This app caps each trade at ${tradeCapUsd(env, 'mainnet')}, and this one is about ${usd.toFixed(2)}. Try a smaller amount.` - : 'This trade could not be valued in dollars right now, so it was not built. Try again shortly.' + ? `This app caps each ${noun} at ${tradeCapUsd(env, 'mainnet')}, and this one is about ${usd.toFixed(2)}. Try a smaller amount.` + : `This ${noun} could not be valued in dollars right now, so it was not built. Try again shortly.` ) } } +/** A send names who it pays and nothing else does. Refused before anything is priced or read. */ +function assertRecipientRule( + chainApi: ChainModule, + request: ChainActionRequest, + tokenIn: ChainToken, + account: string +): void { + if (request.kind !== 'send') { + if (request.recipient !== undefined) { + throw new ChainActionRefused( + 'Only a send takes an address, so this was not built. Nothing was signed or sent.' + ) + } + return + } + if (request.recipient === undefined) { + throw new ChainActionRefused('A send needs an address to go to. Nothing was signed or sent.') + } + const refusal = sendRefusal(chainApi.chain, tokenIn.symbol, request.recipient, account) + if (refusal !== undefined) throw new ChainActionRefused(refusal) +} + +/** + * Whether a step could run after a swap, asked while the swap is unsigned: what the chat alone + * can't know, such as the venues this deployment offers and whether a receiver can take the token. + * The amount isn't known yet, so the dollar cap and the reserve are asked again at the step itself. + */ +export async function assertFollowOnCanRun( + chainApi: ChainModule, + step: FollowOnStep, + account: string +): Promise { + const request: ChainActionRequest = { ...step, amount: '0' } + assertKnownTokens(chainApi, request) + const venue = pickVenue(chainApi, request) + if (venue.assertCanFollow === undefined) { + throw new ChainActionRefused(`${venue.name} can't follow a swap yet.`) + } + await venue.assertCanFollow(step, account) +} + /** * 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 @@ -140,11 +189,127 @@ export async function prepareChainAction( env: Record = process.env ): Promise { const tokenIn = assertKnownTokens(chainApi, request) + assertRecipientRule(chainApi, request, tokenIn, account) await assertAmountWithinCap(chainApi, request, tokenIn, env) + await assertNativeReserve(chainApi, request, account) + await assertHeldForSend(chainApi, request, tokenIn, account) const venue = pickVenue(chainApi, request) const prepared = await venue.prepare(request, account) venue.assertSafe(prepared.signing, account, prepared.context) return prepared } + +/** Native coin kept back for network fees, in display units: a trade may not spend into it. */ +export const NATIVE_FEE_RESERVE: Record = { + solana: '0.005', + sui: '0.05', + near: '0.05', + avalanche: '0.01', + monad: '0.05', +} + +const NATIVE_SYMBOL: Record = { + solana: 'SOL', + sui: 'SUI', + near: 'NEAR', + avalanche: 'AVAX', + monad: 'MON', +} + +/** The balance read may wait on the chain's own deadline, which is far longer than a trade should. */ +const RESERVE_READ_MS = 3000 + +/** + * The wallet's balances, or nothing if the read fails or runs late. What this guards is a doomed + * transaction, and it must never be what blocks a good one. + */ +async function readBalancesBriefly( + chainApi: ChainModule, + account: string +): Promise { + const controller = new AbortController() + let timer: ReturnType | undefined + try { + return await Promise.race([ + chainApi.balances(account, controller.signal), + new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort() + reject(new Error('balance read timed out')) + }, RESERVE_READ_MS) + }), + ]) + } catch { + return undefined + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +/** + * Refuses a send of more than the wallet is known to hold, matched on the token's id. A send a + * wallet signs and the chain then fails costs the fee, and on NEAR reads as maybe having gone out. + * The native coin is left to `assertNativeReserve`, which has already read it. + */ +async function assertHeldForSend( + chainApi: ChainModule, + request: ChainActionRequest, + tokenIn: ChainToken, + account: string +): Promise { + if (request.kind !== 'send') return + if (tokenIn.symbol.toUpperCase() === NATIVE_SYMBOL[chainApi.chain]) return + const reading = await readBalancesBriefly(chainApi, account) + if (reading === undefined) return + const amount = holdingOf(reading, tokenIn) + if (amount === undefined) return + const held = toBase(amount, tokenIn.decimals) + const asked = toBase(request.amount, tokenIn.decimals) + if (held === undefined || asked === undefined || asked <= held) return + throw new ChainActionRefused( + `You hold ${amount} ${tokenIn.symbol}, which is less than ${request.amount}. Nothing was signed or sent.` + ) +} + +/** + * Refuses spending a wallet's whole native balance, which would leave nothing for the fee. + * Reads the balance once and compares in base units. A read that fails or runs late passes: + * this guards a doomed trade, and must never be what blocks a good one. + * + * The native token is the one the reading marks native; the request counts as spending it when + * the module's own table entry for the symbol has that id. NEAR is held natively as "near" but its + * table names the wrapped form `wrap.near`, so there that table entry stands for the native coin. + */ +export async function assertNativeReserve( + chainApi: ChainModule, + request: ChainActionRequest, + account: string +): Promise { + const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn) + if (tokenIn === undefined) return + if (tokenIn.symbol.toUpperCase() !== NATIVE_SYMBOL[chainApi.chain]) return + + const reading = await readBalancesBriefly(chainApi, account) + if (reading === undefined) return + + const native = reading.tokens.find((t) => t.native) + if (native === undefined) return + const isNative = + tokenIn.id === native.id || (chainApi.chain === 'near' && tokenIn.id === WRAP_NEAR) + if (!isNative) return + + const held = toBase(native.amount, native.decimals) + const reserve = toBase(NATIVE_FEE_RESERVE[chainApi.chain], native.decimals) + const asked = toBase(request.amount, native.decimals) + if (held === undefined || reserve === undefined || asked === undefined) return + if (asked <= held - reserve) return + + const spendable = held > reserve ? held - reserve : BigInt(0) + throw new ChainActionRefused( + `Keep about ${NATIVE_FEE_RESERVE[chainApi.chain]} ${native.symbol} for network fees. ` + + `You can spend up to ${fromBase(spendable, native.decimals)} ${native.symbol} here. ` + + 'Nothing was signed or sent.' + ) +} diff --git a/apps/dapp/lib/chains/recipient.ts b/apps/dapp/lib/chains/recipient.ts new file mode 100644 index 00000000..8906d9ef --- /dev/null +++ b/apps/dapp/lib/chains/recipient.ts @@ -0,0 +1,116 @@ +import { isAddress as isSolanaAddress } from '@solana/kit' +import { isAddress as isEvmAddress, zeroAddress } from 'viem' + +import { NEAR_IMPLICIT, NEAR_NAMED } from '../wallet/address' +import { AVALANCHE_TOKENS, EVM_NATIVE_ID, MONAD_TOKENS } from './evm/tokens' +import { NEAR_ACCOUNT } from './near/account' +import { nearTokens } from './near/tokens' +import { SOLANA_TOKENS } from './solana/tokens' +import { SUI_TOKENS } from './sui/tokens' +import type { ChainWithActions } from './types' + +/** Client-safe: the chat refuses from here before any swap, and the server checks again. */ + +export const SEND_VENUE_NAME = 'Wallet transfer' + +const NOTHING_MOVED = 'Nothing was signed or sent.' + +const CHAIN_NAME: Record = { + solana: 'Solana', + sui: 'Sui', + near: 'NEAR', + avalanche: 'Avalanche', + monad: 'Monad', +} + +/** + * What each chain's builder moves as one checked transfer. SOL's table entry is the wrapped mint, + * so its builder would move wrapped tokens under the name SOL. SUI pays the fee from the coins that + * builder would spend, and it refuses to touch the gas coin. NEAR's deposit check is USDC only. + */ +export const SENDABLE: Record = { + solana: ['USDC', 'USDT', 'JitoSOL', 'ONyc'], + sui: ['USDC', 'USDT', 'USDsui'], + near: ['USDC'], + avalanche: ['AVAX', 'USDC', 'USDT'], + monad: ['MON', 'USDC', 'USDT0'], +} + +const SUI_ADDRESS = /^0x[0-9a-fA-F]{64}$/ +const SUI_ZERO = /^0x0{64}$/ +const SOLANA_ZERO = '11111111111111111111111111111111' +const NEAR_ZERO = '0'.repeat(64) + +const EVM_CONTRACTS = [...AVALANCHE_TOKENS, ...MONAD_TOKENS] + .filter((token) => token.id !== EVM_NATIVE_ID) + .map((token) => token.id.toLowerCase()) + +/** The package each coin type lives in. No one holds anything at a package's address. */ +const SUI_PACKAGES = SUI_TOKENS.map((token) => { + const hex = (token.id.split('::')[0] as string).replace(/^0x/, '') + return `0x${hex.padStart(64, '0').toLowerCase()}` +}) + +/** + * Whether money can be sent to this address on this chain, exactly as typed. An EVM address may be + * all lower case, which carries no checksum, but one with mixed case must pass its checksum: a + * wrong one means a typo, and a typo here can't be undone. Zero addresses and the token contracts + * this app knows are refused, since funds sent to either are gone. + */ +export function isValidRecipient(chain: ChainWithActions, address: string): boolean { + switch (chain) { + case 'solana': + return ( + isSolanaAddress(address) && + address !== SOLANA_ZERO && + !SOLANA_TOKENS.some((token) => token.id === address) + ) + case 'sui': + return ( + SUI_ADDRESS.test(address) && + !SUI_ZERO.test(address) && + !SUI_PACKAGES.includes(address.toLowerCase()) + ) + case 'near': + return ( + NEAR_ACCOUNT.test(address) && + (NEAR_IMPLICIT.test(address) || NEAR_NAMED.test(address)) && + address !== NEAR_ZERO && + !nearTokens.some((token) => token.id === address) + ) + case 'avalanche': + case 'monad': + return ( + isEvmAddress(address, { strict: true }) && + address.toLowerCase() !== zeroAddress && + !EVM_CONTRACTS.includes(address.toLowerCase()) + ) + } +} + +function isSameAccount(a: string, b: string): boolean { + return a.startsWith('0x') ? a.toLowerCase() === b.toLowerCase() : a === b +} + +/** + * The sentence for a send that can't go, or nothing. `symbol` and `account` are left out where they + * aren't known yet, and the check runs again where they are. + */ +export function sendRefusal( + chain: ChainWithActions, + symbol: string | undefined, + recipient: string, + account?: string +): string | undefined { + const name = CHAIN_NAME[chain] + if (!isValidRecipient(chain, recipient)) { + return `That is not an address that can receive on ${name}, so I won't send to it. Check the address. ${NOTHING_MOVED}` + } + if (account !== undefined && isSameAccount(recipient, account)) { + return `That address is your own wallet, so there is nothing to send. ${NOTHING_MOVED}` + } + if (symbol === undefined) return undefined + const wanted = symbol.trim().toUpperCase() + if (SENDABLE[chain].some((listed) => listed.toUpperCase() === wanted)) return undefined + return `Sending ${symbol} isn't available on ${name} yet. You can send ${SENDABLE[chain].join(', ')}. ${NOTHING_MOVED}` +} diff --git a/apps/dapp/lib/chains/send.ts b/apps/dapp/lib/chains/send.ts new file mode 100644 index 00000000..b88cf715 --- /dev/null +++ b/apps/dapp/lib/chains/send.ts @@ -0,0 +1,390 @@ +import { createHmac } from 'node:crypto' + +import { TransactionDataBuilder } from '@mysten/sui/transactions' +import { fromBase64 } from '@mysten/sui/utils' + +import { walletSends } from '../weave/cashout-view' +import { nearDeposit } from '../weave/deposit/near' +import { assertDepositIsNotTokenAccount, 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 } from './evm/submit' +import { EVM_CHAINS, evmExplorerUrl, type EvmChain } from './evm/tokens' +import { NEAR_ACCOUNT, NEAR_TX_HASH } from './near/account' +import { isRegistered } from './near/rpc' +import { assertTokenTransferSafe } from './near/safety' +import { buildDepositTransaction } from './near/swap' +import { explorerUrl as nearExplorerUrl, formatUnits, nearTokens } from './near/tokens' +import { sameProof, secret } from './oneclick-origin' +import { isValidRecipient, SEND_VENUE_NAME, sendRefusal } from './recipient' +import { explorerUrl as solanaExplorerUrl, feePayerSignature } from './solana/submit' +import { SOLANA_TOKENS } from './solana/tokens' +import { SUI_TOKENS } from './sui/tokens' +import { + ChainActionRefused, + tokenBySymbol, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type ChainWithActions, + type PreparedAction, + type SignedPayload, + type SigningPayload, + type SubmitInput, + type SubmitResult, +} from './types' + +/** + * Moves one token to an address a person typed. It can't be undone, so nothing here decides what a + * transfer is. Each chain's deposit builder builds and checks exactly one, as it does for a Weave + * order and a 1Click swap, with the recipient standing where the deposit address does. + * + * The browser carries `context` between prepare and submit, so the recipient, token and amount in + * it are believed only once `readIssued` proves this server wrote them. With no secret no proof can + * be issued or read, so the module offers no such venue and everything here refuses. + */ + +export const SEND_VENUE_ID = 'send' +export const SEND_NOTE = "Transfers can't be undone. Check the address." + +const NOTHING_MOVED = 'Nothing was signed or sent.' +const UNAVAILABLE = `Sending isn't available here right now. ${NOTHING_MOVED}` +/** Both can be said after the wallet has signed, at submit, where only "sent" is still true. */ +const NOT_ISSUED = "This transfer's details are not what this app issued. Nothing was sent." +const NOT_THE_TRANSFER = + 'This is not the transfer that was asked for, so it was stopped. Nothing was sent.' +const NOT_SENT = 'This transfer was not sent, so nothing left your wallet. You can try again.' + +const SOLANA_RENT_NOTE = + "If this address has no account for this token yet, your wallet also pays about 0.00204 SOL in rent to open one, and you don't get it back." + +/** Inside the submit route's 60 seconds, leaving room to answer. */ +const CONFIRM_WAIT_MS = 50_000 + +/** Each chain's builder, and what it takes to follow a transfer on that chain. */ +interface Mover { + family: 'solana' | 'sui' | 'near' | 'evm' + tokens: readonly ChainToken[] + deposit: Pick + /** What only the chain can say about the receiver. Asked before a swap as well as at the build. */ + assertReceiver?: (recipient: string, token: ChainToken) => Promise + assertFunds?: (account: string, token: ChainToken, amount: string) => Promise + notes?: readonly string[] + hashOf: (signed: SignedPayload) => string | undefined + explorerUrl: (hash: string) => string +} + +/** What the network's sentences say that isn't true of a transfer. */ +const OTHER_WORDS = /\b(?:trades?|prices?|swaps?|orders?|deposits?)\b/i + +/** The builders speak of orders and swaps. What is on this path is said for a transfer instead. */ +function sendWords(text: string): string { + return text + .replace(/This order[’']s deposit address/g, 'That address') + .replace(/This order[’']s amount/g, 'This amount') + .replace(/this order[’']s amount/g, 'this amount') + .replace(/\b(?:the|this) swap\b/g, 'this transfer') +} + +async function inSendWords(work: () => Promise): Promise { + try { + return await work() + } catch (e) { + if (e instanceof ChainActionRefused) throw new ChainActionRefused(sendWords(e.message)) + throw e + } +} + +const NEAR_USDC = tokenBySymbol(nearTokens, 'USDC') as ChainToken + +/** + * The shared check with the receiver's shape opened to named accounts, then bound exactly, in one + * action on USDC. A send never pays for anyone's storage, so `storage_deposit` can't pass here. + */ +function assertNearSend(payload: SigningPayload, expectation: DepositExpectation): void { + assertTokenTransferSafe(payload, expectation.account, NEAR_ACCOUNT) + if (payload.family !== 'near') throw new ChainActionRefused(NOT_THE_TRANSFER) + const transaction = payload.transactions[0] + const args = transaction?.actions[0]?.params.args + if ( + transaction?.receiverId !== NEAR_USDC.id || + transaction.actions.length !== 1 || + args?.['receiver_id'] !== expectation.depositAddress || + args?.['amount'] !== toBaseUnits(expectation.amount, NEAR_USDC.decimals) + ) { + throw new ChainActionRefused(NOT_THE_TRANSFER) + } +} + +const NEAR_MOVER: Mover = { + family: 'near', + tokens: nearTokens, + deposit: { + build: async (expectation) => { + const transaction = await buildDepositTransaction( + NEAR_USDC, + expectation.account, + expectation.depositAddress, + toBaseUnits(expectation.amount, NEAR_USDC.decimals) + ) + const payload: SigningPayload = { family: 'near', transactions: [transaction] } + assertNearSend(payload, expectation) + return payload + }, + assertSafe: assertNearSend, + submit: nearDeposit.submit, + }, + assertReceiver: async (recipient) => { + let registered: boolean + try { + registered = await isRegistered(NEAR_USDC.id, recipient) + } catch { + throw new ChainActionRefused(`NEAR's network could not be read just now. ${NOTHING_MOVED}`) + } + if (!registered) { + throw new ChainActionRefused( + `${recipient} is not registered with USDC on NEAR, so this transfer would fail. They need to register with the token first. ${NOTHING_MOVED}` + ) + } + }, + hashOf: (signed) => { + const hash = signed.family === 'near' ? signed.hashes[signed.hashes.length - 1] : undefined + return hash !== undefined && NEAR_TX_HASH.test(hash) ? hash : undefined + }, + explorerUrl: nearExplorerUrl, +} + +const SOLANA_MOVER: Mover = { + family: 'solana', + tokens: SOLANA_TOKENS, + deposit: solanaDeposit, + assertReceiver: (recipient, token) => + assertDepositIsNotTokenAccount(recipient, token, { refusePrograms: true }), + notes: [SOLANA_RENT_NOTE], + hashOf: (signed) => + signed.family === 'solana' ? feePayerSignature(signed.signedTransaction) : undefined, + explorerUrl: solanaExplorerUrl, +} + +const SUI_MOVER: Mover = { + family: 'sui', + tokens: SUI_TOKENS, + deposit: suiDeposit, + hashOf: (signed) => { + if (signed.family !== 'sui') return undefined + try { + return TransactionDataBuilder.getDigestFromBytes(fromBase64(signed.transaction)) + } catch { + return undefined + } + }, + explorerUrl: (hash) => `https://suiscan.xyz/mainnet/tx/${hash}`, +} + +function evmMover(chain: EvmChain): Mover { + const deposit = evmDeposit(chain) + return { + family: 'evm', + tokens: EVM_CHAINS[chain].tokens, + deposit, + assertFunds: deposit.assertFunds, + hashOf: (signed) => + signed.family === 'evm' && EVM_HASH.test(signed.hash) ? signed.hash : undefined, + explorerUrl: (hash) => evmExplorerUrl(chain, hash), + } +} + +function moverFor(chain: ChainWithActions): Mover { + if (chain === 'solana') return SOLANA_MOVER + if (chain === 'sui') return SUI_MOVER + if (chain === 'near') return NEAR_MOVER + return evmMover(chain) +} + +interface Issued { + recipient: string + /** The token's id. */ + token: string + /** Base units. */ + amount: string +} + +/** + * Proves a transfer came from this server, for this chain and account. Every field that decides + * where funds go is signed. The prefix keeps this from passing as a 1Click or session signature, + * which share the secret. + */ +function proofOf(chain: ChainWithActions, account: string, issued: Issued, key: string): string { + const message = JSON.stringify([chain, account, issued.token, issued.amount, issued.recipient]) + return createHmac('sha256', key).update(`chain-send:${message}`).digest('base64url') +} + +/** A venue that moves one token to an address, or a refusal from whichever check fails first. */ +export function createSendVenue(chain: ChainWithActions): ChainVenue { + const mover = moverFor(chain) + + /** The transfer this server issued, or a refusal. Never returns anything it did not sign. */ + function readIssued( + account: string, + context: Record | undefined + ): DepositExpectation { + const key = secret() + const recipient = context?.['recipient'] + const tokenId = context?.['token'] + const amount = context?.['amount'] + const proof = context?.['proof'] + if ( + key === undefined || + recipient === undefined || + tokenId === undefined || + amount === undefined || + proof === undefined || + !sameProof(proof, proofOf(chain, account, { recipient, token: tokenId, amount }, key)) || + !isValidRecipient(chain, recipient) + ) { + throw new ChainActionRefused(NOT_ISSUED) + } + const token = mover.tokens.find((t) => t.id === tokenId) + if (token === undefined) throw new ChainActionRefused(NOT_ISSUED) + return { + account, + depositAddress: recipient, + memo: undefined, + amount: formatUnits(amount, token.decimals, token.decimals), + token, + } + } + + function tokenFor(symbol: string): ChainToken { + const token = tokenBySymbol(mover.tokens, symbol) + if (token === undefined) throw new ChainActionRefused(NOT_THE_TRANSFER) + return token + } + + function assertSendable(symbol: string, recipient: string | undefined, account: string): string { + if (recipient === undefined) { + throw new ChainActionRefused(`A send needs an address to go to. ${NOTHING_MOVED}`) + } + const refusal = sendRefusal(chain, symbol, recipient, account) + if (refusal !== undefined) throw new ChainActionRefused(refusal) + return recipient + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const key = secret() + if (key === undefined) throw new ChainActionRefused(UNAVAILABLE) + const recipient = assertSendable(request.tokenIn, request.recipient, account) + const token = tokenFor(request.tokenIn) + const amount = toBaseUnits(request.amount, token.decimals) + const expectation: DepositExpectation = { + account, + depositAddress: recipient, + memo: undefined, + amount: request.amount, + token, + } + const { signing, fundsNotes } = await inSendWords(async () => { + await mover.assertReceiver?.(recipient, token) + const notes = (await mover.assertFunds?.(account, token, request.amount)) ?? [] + return { signing: await mover.deposit.build(expectation), fundsNotes: notes.map(sendWords) } + }) + const issued: Issued = { recipient, token: token.id, amount } + return { + venueId: SEND_VENUE_ID, + venueName: SEND_VENUE_NAME, + kind: 'send', + summary: `Send ${request.amount} ${token.symbol} to ${recipient}.`, + pay: { amount: request.amount, symbol: token.symbol }, + notes: [SEND_NOTE, ...(mover.notes ?? []), ...fundsNotes], + signing, + context: { ...issued, proof: proofOf(chain, account, issued, key) }, + } + } + + /** What came back, against what was issued. The wording stays about a transfer on every path. */ + function assertSafe( + payload: SigningPayload, + account: string, + context?: Record + ): void { + const expectation = readIssued(account, context) + try { + mover.deposit.assertSafe(payload, expectation) + } catch (e) { + if (e instanceof ChainActionRefused) throw new ChainActionRefused(NOT_THE_TRANSFER) + throw e + } + } + + /** + * An answer lacks `pending` only if it proves nothing moved. On Solana and Sui this server is + * what sends, so a refusal here does. A wallet that sends for itself has sent by now, so nothing + * said here can prove that: every answer there short of a confirmation is pending. + */ + async function submit(input: SubmitInput): Promise { + const { account, signed, context } = input + const sends = walletSends(mover.family) + const unconfirmed = (): SubmitResult => { + const hash = signed.family === mover.family ? mover.hashOf(signed) : undefined + const where = hash === undefined ? "your wallet's activity" : mover.explorerUrl(hash) + return { + ok: false, + pending: true, + error: `This transfer may have gone out${hash === undefined ? '' : ` as ${hash}`} and isn't confirmed yet. Don't send it again until you've checked ${where}.`, + } + } + + if (signed.family !== mover.family) { + return sends ? unconfirmed() : { ok: false, error: NOT_THE_TRANSFER } + } + let expectation: DepositExpectation + try { + expectation = readIssued(account, context) + } catch (e) { + if (!(e instanceof ChainActionRefused)) throw e + return sends ? unconfirmed() : { ok: false, error: e.message } + } + + let timer: ReturnType | undefined + const limit = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), CONFIRM_WAIT_MS) + }) + try { + const result = await Promise.race([mover.deposit.submit(signed, expectation), limit]) + if (result === undefined) return unconfirmed() + if (result.ok) return result + if (result.pending === true || sends) return unconfirmed() + // A sentence about the fee or the balance says what to do. Only other words are replaced. + return { + ok: false, + error: OTHER_WORDS.test(result.error) + ? `This transfer did not go through, so your ${expectation.token?.symbol ?? 'funds'} did not move. You can try again.` + : result.error, + } + } catch (e) { + // A refusal here comes before the builder sends, and may be only a read that failed, so it + // says what is true of both. Anything else may be after the send. + return e instanceof ChainActionRefused && !sends + ? { ok: false, error: NOT_SENT } + : unconfirmed() + } finally { + clearTimeout(timer) + } + } + + return { + id: SEND_VENUE_ID, + name: SEND_VENUE_NAME, + kinds: ['send'], + prepare, + assertSafe, + submit, + assertCanFollow: async ({ tokenIn, recipient }, account) => { + if (secret() === undefined) throw new ChainActionRefused(UNAVAILABLE) + const to = assertSendable(tokenIn, recipient, account) + await inSendWords(async () => mover.assertReceiver?.(to, tokenFor(tokenIn))) + }, + } +} diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts new file mode 100644 index 00000000..d86a485a --- /dev/null +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -0,0 +1,1044 @@ +import type { ChainActionPhase } from '../../hooks/use-chain-action' +import { walletFetch } from '../api/network-fetch' +import { + NO_CASHOUTS, + accountMismatch, + unsupportedCurrencyReply, + walletSends, + type CashoutCurrency, +} from '../weave/cashout-view' +import type { CashoutCurrencyCode, CashoutRoute } from '../weave/parse-cashout' +import { MAX_COST_PERCENT } from '../weave/types' +import { AVALANCHE_TOKENS, MONAD_TOKENS } from './evm/tokens' +import { nearTokens } from './near/tokens' +import { chainNamesOf, chainTokenWord } from './parse-action' +import { + cashoutAmountFrom, + deliveredAmount, + fromBase, + strandedReply, + toBase, + type ChainSequenceRequest, + type FollowOn, +} from './sequence' +import { SOLANA_TOKENS } from './solana/tokens' +import { SUI_TOKENS } from './sui/tokens' +import { + tokenBySymbol, + type ChainActionKind, + type ChainActionRequest, + type ChainToken, + type ChainWithActions, + type SigningPayload, + type TokenBalance, + type WalletBalances, +} from './types' + +/** + * A swap, then what comes after it. The swap is an ordinary chain action, signed on its own card, + * and this reads how it ended. `settled` is the only answer that lets step 2 be offered. + * + * `next` is a cash-out's form. A deposit, stake or send has a card of its own instead, the same + * `chainAction` as step 1: `following` while it is live, `followed` once it settled, and + * `unsettled` once it may have moved money without being seen to finish. Only `following` can go + * back to `ready`, and only on an answer that proves nothing moved. + */ +export type SequenceStage = + | 'swap' + | 'measuring' + | 'ready' + | 'next' + | 'following' + | 'followed' + | 'unsettled' + | 'stranded' + | 'done' + +export const MEASURE_INTERVAL_MS = 3_000 +export const MEASURE_ATTEMPTS = 20 +export const BEFORE_RETRY_MS = 1_500 + +/** As long as the balances route may run, so one read that hangs still ends as one attempt. */ +const READ_TIMEOUT_MS = 20_000 + +export const CASHOUT_UNDER_WAY = + 'A cash-out is already under way. Follow it here, and do not send it again.' + +/** What each follow-on is called, in the words a person would use. */ +function nounOf(followOn: FollowOn): string { + if (followOn.kind === 'cashout') return 'cash-out' + return followOn.kind === 'supply' ? 'deposit' : followOn.kind +} + +/** Step 1 may or may not have moved money, so this says neither. */ +export const UNCONFIRMED_SWAP = + "This swap is not confirmed, so the cash-out will not start. Check your wallet's activity before doing anything else. Nothing was paid out." + +export const SWAP_NEXT_STEP = + 'Step 2 waits for your press. It uses what the swap actually delivers.' + +export const BALANCE_UNREAD = + 'I could not read your USDC balance, so if you sign this swap the cash-out will not follow. You would hold the USDC and can cash it out yourself.' + +export const TOO_SMALL_FOR_WEAVE = + 'Weave would refuse a cash-out this small, so this swap would leave you holding the USDC.' + +const LEFT_HOLDING = 'This swap would leave you holding the USDC.' + +/** Beside the card's own words for the failure, which may or may not say nothing moved. */ +export const CASHOUT_NOT_STARTED = 'The cash-out did not start.' + +function unconfirmedSwap(followOn: FollowOn): string { + return followOn.kind === 'cashout' + ? UNCONFIRMED_SWAP + : `This swap is not confirmed, so the ${nounOf(followOn)} will not start. Check your wallet's activity before doing anything else.` +} + +function notStarted(followOn: FollowOn): string { + return followOn.kind === 'cashout' + ? CASHOUT_NOT_STARTED + : `The ${nounOf(followOn)} did not start.` +} + +/** + * What step 1's card says about where the sequence stands. Nothing while it is still open. + * `signed` is `chainAction.signed`: false on a failure means a signature cannot have moved money, + * as with a refusal, a clean failure at the wallet or a clean refusal at submit, and the card lets + * the person try again. Leaving it out assumes the worse. + */ +export function stageAfterSwap( + phase: ChainActionPhase, + signed: boolean = true +): SequenceStage | undefined { + if (phase === 'settled') return 'measuring' + if (phase === 'pending') return 'stranded' + if (phase === 'failed') return signed ? 'stranded' : 'done' + return undefined +} + +/** + * The next stage as the card moves. A plain failure can be retried from the same race, so a + * rebuild takes the sequence back up. Nothing else the card does can change a stage already reached. + */ +export function stageOnCard( + current: SequenceStage, + phase: ChainActionPhase, + signed: boolean +): SequenceStage { + if (current === 'done') return phase === 'building' || phase === 'review' ? 'swap' : 'done' + if (current !== 'swap') return current + return stageAfterSwap(phase, signed) ?? 'swap' +} + +export interface SequenceProgress { + stage: SequenceStage + /** Step 1 is known to have settled. A settled swap is what "the swap went through" may be said of. */ + settled: boolean + error?: string + /** Step 2's own build has started, so what the card says from here is about step 2. */ + opened?: boolean +} + +/** + * The sequence as step 1's card moves, with what to say. A swap that settled is past the card, and + * the rest of it is measured from the chain. An unknown `before` can't be measured from, so it + * strands at once rather than guess. + */ +export function moveOnCard( + progress: SequenceProgress, + phase: ChainActionPhase, + signed: boolean, + beforeKnown: boolean, + request: ChainSequenceRequest +): SequenceProgress { + if (progress.settled) return progress + const stage = stageOnCard(progress.stage, phase, signed) + if (stage === progress.stage) return progress + if (stage === 'measuring') { + return beforeKnown + ? { stage, settled: true } + : { stage: 'stranded', settled: true, error: unmeasuredReply(request) } + } + if (stage === 'stranded') { + return { stage, settled: false, error: unconfirmedSwap(request.followOn) } + } + if (stage === 'done') return { stage, settled: false, error: notStarted(request.followOn) } + return { stage, settled: false } +} + +/** + * The sequence as step 2's card moves, for a follow-on whose card is `chainAction` itself. Step 1 + * stays flagged settled, so `moveOnCard` ignores all of this and nothing is measured again. + * + * Until its own build starts, the card may still show how step 1 ended. Once it has, `signed` is + * the latch, and false on a failure or a closed card means no signature can have moved money. That + * alone returns to `ready`. Anything else ends here, so no press can ever lift a signed step 2's + * latch: `ready` is the only stage that offers the button. Only a card of the follow-on's own kind + * settling is step 2 finishing. Anything else that settled is not it, and is not ticked as it. + */ +export function moveOnSecond( + progress: SequenceProgress, + phase: ChainActionPhase, + signed: boolean, + request: ChainSequenceRequest, + delivered: string | undefined, + cardKind: ChainActionKind | undefined +): SequenceProgress { + if (progress.stage !== 'following') return progress + if (progress.opened !== true) { + return phase === 'building' ? { ...progress, opened: true } : progress + } + if (phase === 'settled' && cardKind === request.followOn.kind) { + return { stage: 'followed', settled: true } + } + const clean = (phase === 'failed' || phase === 'idle') && !signed + if (clean) return { stage: 'ready', settled: true } + if (phase !== 'settled' && phase !== 'pending' && phase !== 'failed' && phase !== 'idle') { + return progress + } + const arrived = + delivered === undefined + ? '' + : ` and about ${delivered} ${outputSymbol(request)} reached your wallet` + return { + stage: 'unsettled', + settled: true, + error: `The swap went through${arrived}. The ${nounOf(request.followOn)} has not been confirmed as finished, so check your wallet's activity before trying it again.`, + } +} + +/** + * The stage as the cash-out form moves. Leaving the form is declining it for now. A cash-out under + * way or paid ends the sequence, so the button can never offer the same USDC twice. + */ +export function stageOnForm( + current: SequenceStage, + form: { open: boolean; locked: boolean; paid: boolean } +): SequenceStage { + if (current !== 'next') return current + if (form.locked || form.paid) return 'done' + return form.open ? 'next' : 'ready' +} + +export function stageAfterMeasure( + delivered: string | undefined, + attempts: number, + maxAttempts: number +): SequenceStage | 'keep' { + if (delivered !== undefined) return 'ready' + return attempts >= maxAttempts ? 'stranded' : 'keep' +} + +const TOKEN_TABLES: Record = { + solana: SOLANA_TOKENS, + sui: SUI_TOKENS, + near: nearTokens, + avalanche: AVALANCHE_TOKENS, + monad: MONAD_TOKENS, +} + +/** + * The token step 1 delivers, from the prepared card's symbol and this chain's own table, which is + * what gives the id to measure by. Nothing if the card names something other than what was asked. + * A native output measures low, since the fee comes out of the same balance, and never high. + */ +export function outputToken( + chain: string, + receiveSymbol: string | undefined, + asked: string | undefined +): ChainToken | undefined { + if (receiveSymbol === undefined || asked === undefined) return undefined + if (receiveSymbol.toUpperCase() !== asked.toUpperCase()) return undefined + const table = TOKEN_TABLES[chain as ChainWithActions] + return table === undefined ? undefined : tokenBySymbol(table, receiveSymbol) +} + +/** + * How much of a token an answer shows, matched on id. Missing from an answer that lists everything + * means none held. Missing from any other answer means not known, never zero: a `before` of zero + * that was not read would make everything already held look delivered. + */ +export function holdingOf( + balances: Pick & { tokens: readonly TokenBalance[] }, + token: Pick +): string | undefined { + const held = balances.tokens.find((t) => t.id === token.id) + if (held !== undefined) return held.amount + return balances.complete && balances.unread?.includes(token.symbol) !== true ? '0' : undefined +} + +/** One read of a token's balance, or nothing on a failed read, a 503 included. */ +export async function readHolding( + chain: string, + account: string, + token: Pick, + signal?: AbortSignal +): Promise { + const limit = new AbortController() + const stop = (): void => limit.abort() + const timer = setTimeout(stop, READ_TIMEOUT_MS) + signal?.addEventListener('abort', stop, { once: true }) + try { + const response = await fetch( + `/api/chain/${chain}/balances?account=${encodeURIComponent(account)}`, + { cache: 'no-store', signal: limit.signal } + ) + if (!response.ok) return undefined + const body = (await response.json()) as Partial + return Array.isArray(body.tokens) ? holdingOf(body as WalletBalances, token) : undefined + } catch { + return undefined + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', stop) + } +} + +/** The output token's balance before the swap. Anything but `known` strands at settled. */ +export type Before = + | { status: 'unknown' } + | { status: 'reading'; account: string; token: ChainToken } + | { status: 'known'; amount: string; account: string; token: ChainToken } + +export type BeforeStatus = Before['status'] + +/** + * Whether a `before` read that just answered is still from before the swap. It holds while the card + * is being built or is on offer. In the wallet it holds only where the wallet sends nothing itself. + * Any later and the swap may be in the figure, which would measure too little. + */ +export function beforeReadCounts( + phase: ChainActionPhase, + family: SigningPayload['family'] | undefined +): boolean { + if (phase === 'building' || phase === 'review') return true + return phase === 'signing' && family !== undefined && !walletSends(family) +} + +/** Whether the card that just came up can use the read already made for it. A decline can. */ +export function keepsBefore(before: Before, account: string, tokenId: string): boolean { + return before.status !== 'unknown' && before.account === account && before.token.id === tokenId +} + +/** Reads the balance, and once more after a pause if that failed and the card is still unsigned. */ +export async function readBeforeSwap( + read: () => Promise, + stillOpen: () => boolean, + wait: (ms: number) => Promise = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) +): Promise { + const first = await read() + if (first !== undefined || !stillOpen()) return first + await wait(BEFORE_RETRY_MS) + return stillOpen() ? read() : undefined +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer) + resolve() + }, + { once: true } + ) + }) +} + +/** + * Reads until the balance rises or the attempts run out, one read at a time. Nothing once aborted, + * so a measure that was cleared decides nothing. + */ +export async function measureDelivered(args: { + before: string + decimals: number + read: (signal: AbortSignal) => Promise + signal: AbortSignal + wait?: (ms: number, signal: AbortSignal) => Promise +}): Promise<{ stage: 'ready'; delivered: string } | { stage: 'stranded' } | undefined> { + const { before, decimals, read, signal, wait = sleep } = args + for (let attempts = 1; ; attempts += 1) { + if (signal.aborted) return undefined + const after = await read(signal) + if (signal.aborted) return undefined + const delivered = deliveredAmount(before, after, decimals) + const stage = stageAfterMeasure(delivered, attempts, MEASURE_ATTEMPTS) + if (stage !== 'keep') { + return delivered !== undefined ? { stage: 'ready', delivered } : { stage: 'stranded' } + } + await wait(MEASURE_INTERVAL_MS, signal) + } +} + +/** + * What the swap's card says about what follows it, in place of promising what won't happen. Leaving + * out the request means a cash-out. `quoted` is Weave's word on a cash-out's size, so nothing else + * ever shows it. + */ +export function swapLine( + phase: ChainActionPhase, + status: BeforeStatus, + quoted: string | undefined, + request?: ChainSequenceRequest +): string { + const unread = status === 'unknown' && (phase === 'review' || phase === 'signing') + if (request === undefined || request.followOn.kind === 'cashout') { + return unread ? BALANCE_UNREAD : (quoted ?? SWAP_NEXT_STEP) + } + if (!unread) return SWAP_NEXT_STEP + const symbol = outputSymbol(request) + return `I could not read your ${symbol} balance, so if you sign this swap the ${nounOf(request.followOn)} will not follow. You would hold the ${symbol}.` +} + +export type QuoteStep = + | { kind: 'none' } + | { kind: 'note'; note: string } + | { kind: 'ask'; amount: string } + +/** What to ask Weave about a card's receive: the amount the button would open at, not all of it. */ +export function quoteStep(receive: string | undefined): QuoteStep { + if (receive === undefined) return { kind: 'none' } + const amount = cashoutAmountFrom(receive) + return amount === undefined + ? { kind: 'note', note: TOO_SMALL_FOR_WEAVE } + : { kind: 'ask', amount } +} + +/** + * What a quote's answer means for this card. A cost past what the order route accepts and a refusal + * of the amount are said. Anything else, such as a busy or failed read, says nothing about the size. + */ +export function quoteNote( + status: number, + body: { error?: unknown; message?: unknown; costPercent?: unknown } +): string | undefined { + if (status === 200) { + const { costPercent } = body + return typeof costPercent === 'number' && costPercent > MAX_COST_PERCENT + ? TOO_SMALL_FOR_WEAVE + : undefined + } + if (status === 400) return body.error === 'invalid_amount' ? TOO_SMALL_FOR_WEAVE : undefined + if (status !== 422) return undefined + const { message } = body + const plain = typeof message === 'string' && message.includes(' ') && !/[<>{}]/.test(message) + return plain ? `${message} ${LEFT_HOLDING}` : TOO_SMALL_FOR_WEAVE +} + +/** A read of the price only: this route makes no order. Nothing on a failed or slow read. */ +export async function readCashoutNote( + chain: string, + currency: string, + amount: string +): Promise { + try { + const query = new URLSearchParams({ chain, currency, amount }) + const response = await walletFetch(`/api/cashout/quote?${query.toString()}`, { + signal: AbortSignal.timeout(READ_TIMEOUT_MS), + }) + return quoteNote(response.status, (await response.json()) as Parameters[1]) + } catch { + return undefined + } +} + +/** What reached the wallet. For a cash-out, which no request means, also whether it is enough. */ +export function deliveredLine(delivered: string, request?: ChainSequenceRequest): string { + if (request !== undefined && request.followOn.kind !== 'cashout') { + return `About ${delivered} ${outputSymbol(request)} reached your wallet.` + } + return cashoutAmountFrom(delivered) === undefined + ? `About ${delivered} USDC reached your wallet, which is too small to cash out. Nothing was paid out.` + : `About ${delivered} USDC reached your wallet.` +} + +export type ReadyAction = + | { kind: 'open'; amount: string } + /** No button: there is nothing to cash out. */ + | { kind: 'too-small'; message: string } + /** The button stays, since each of these can change: this press does nothing. */ + | { kind: 'refused'; message: string } + +/** What the step 2 button does. `account` is the wallet step 1 was built for. */ +export function readyAction( + delivered: string, + locked: boolean, + connected: string | undefined, + account: string +): ReadyAction { + const amount = cashoutAmountFrom(delivered) + if (amount === undefined) return { kind: 'too-small', message: deliveredLine(delivered) } + if (locked) return { kind: 'refused', message: CASHOUT_UNDER_WAY } + const wrongWallet = accountMismatch(connected, account) + if (wrongWallet !== undefined) return { kind: 'refused', message: wrongWallet } + return { kind: 'open', amount } +} + +/** The one line a sequence leaves in the transcript as it reaches each ending. */ +export function sequenceOutcome( + stage: SequenceStage | undefined, + delivered: string | undefined, + error: string | undefined, + request?: ChainSequenceRequest +): string | undefined { + if (stage === 'ready' && delivered !== undefined) return deliveredLine(delivered, request) + return stage === 'stranded' || stage === 'done' || stage === 'unsettled' ? error : undefined +} + +/** A token as the tables spell it, JitoSOL and not JITOSOL, however it was typed. */ +function listedSymbol(word: string): string { + const listed = tokenBySymbol(Object.values(TOKEN_TABLES).flat(), word) + return listed?.symbol ?? word.toUpperCase() +} + +function outputSymbol(request: ChainSequenceRequest): string { + return listedSymbol(request.swap.tokenOut ?? '') +} + +/** + * Said when a swap that settled is left before step 2 ran, or nothing where there is no news. A + * step 2 being signed can't be left: the chat holds new messages until the wallet has answered. + */ +export function endedNote( + stage: SequenceStage | undefined, + delivered: string | undefined, + request: ChainSequenceRequest | null +): string | undefined { + if (request === null) return undefined + if (stage !== 'measuring' && stage !== 'ready' && stage !== 'next' && stage !== 'following') { + return undefined + } + return strandedReply(outputSymbol(request), delivered, request.followOn) +} + +/** + * Whether a message leaves the run where it stands. One that starts an action ends it. Anything + * else keeps it while a step is left to take: a question, a refusal, or what only the model can + * read, until it says. A run before its swap settled has no such step, and the message closes its + * race and card anyway, so that ends as it always has. + */ +export function keepsRun(stage: SequenceStage | undefined, startsAction: boolean): boolean { + if (startsAction) return false + return stage === 'measuring' || stage === 'ready' || stage === 'next' || stage === 'following' +} + +/** Step 1's sentence after the measure could not finish. */ +export function unmeasuredReply(request: ChainSequenceRequest): string { + return strandedReply(outputSymbol(request), undefined, request.followOn) +} + +export function stepLabels(request: ChainSequenceRequest): [string, string] { + const { amount, tokenIn } = request.swap + const first = `Swap ${amount} ${listedSymbol(tokenIn)} to ${outputSymbol(request)}` + return [first, secondLabel(request.followOn)] +} + +function secondLabel(followOn: FollowOn): string { + switch (followOn.kind) { + case 'cashout': + return followOn.currency !== undefined ? `Cash out to ${followOn.currency}` : 'Cash out' + case 'supply': + return followOn.venue !== undefined ? `Deposit on ${followOn.venue}` : 'Deposit' + case 'stake': + return followOn.venue !== undefined ? `Stake on ${followOn.venue}` : 'Stake' + case 'send': + return `Send to ${followOn.recipient}` + } +} + +/** What the button says for a follow-on with a card of its own. A cash-out's button is its own. */ +export function secondStepLabel( + request: ChainSequenceRequest, + delivered: string +): string | undefined { + const { followOn } = request + if (followOn.kind === 'cashout') return undefined + const verb = followOn.kind === 'supply' ? 'Deposit' : followOn.kind === 'stake' ? 'Stake' : 'Send' + return `${verb} ${delivered} ${outputSymbol(request)}` +} + +/** An amount cut to a token's places, never rounded up. Nothing if that leaves nothing. */ +function flooredAmount(text: string, decimals: number): string | undefined { + if (!/^\d+(?:\.\d+)?$/.test(text)) return undefined + const [whole = '0', fraction = ''] = text.split('.') + const base = toBase(`${whole}.${fraction.slice(0, decimals) || '0'}`, decimals) + return base === undefined || base === BigInt(0) ? undefined : fromBase(base, decimals) +} + +/** + * Step 2's request: the measured amount of the token the swap delivered. Nothing for a cash-out, + * which has a form instead, or when too little is left to move. + */ +export function nextRequest( + request: ChainSequenceRequest, + delivered: string, + decimals: number +): ChainActionRequest | undefined { + const { followOn, swap } = request + const amount = flooredAmount(delivered, decimals) + if (followOn.kind === 'cashout' || amount === undefined || swap.tokenOut === undefined) { + return undefined + } + const tokenIn = swap.tokenOut + if (followOn.kind === 'send') { + return { kind: 'send', amount, tokenIn, recipient: followOn.recipient } + } + return { + kind: followOn.kind, + amount, + tokenIn, + ...(followOn.venue !== undefined ? { venue: followOn.venue } : {}), + } +} + +/** + * What step 2 moves. A deposit, stake or send has no form to correct it, so if more than 2% over the + * card's quote arrived, money from elsewhere came in as well, and only the quoted amount is this + * swap's. No quote that can be read means what was measured. A cash-out's form can be edited. + */ +export function stepAmount( + request: ChainSequenceRequest, + delivered: string, + quoted: string | undefined +): { amount: string; note?: string } { + const measured = { amount: delivered } + if (request.followOn.kind === 'cashout' || quoted === undefined) return measured + const placesOf = (text: string): number => (text.split('.')[1] ?? '').length + const places = Math.max(placesOf(delivered), placesOf(quoted)) + const got = toBase(delivered, places) + const said = toBase(quoted, places) + if (got === undefined || said === undefined || said === BigInt(0)) return measured + if (got * BigInt(100) <= said * BigInt(102)) return measured + return { + amount: quoted, + note: `More arrived than this swap should bring, so this uses the quoted ${quoted} ${outputSymbol(request)}.`, + } +} + +/** + * Step 2 as a request of its own, as an example with a made-up amount: the token is what the swap + * delivered. Nothing for a cash-out, whose form is its own way back. + */ +export function stepOnItsOwn(request: ChainSequenceRequest): string | undefined { + const symbol = outputSymbol(request) + switch (request.followOn.kind) { + case 'cashout': + return undefined + case 'supply': + return `deposit 5 ${symbol}` + case 'stake': + return `stake 5 ${symbol}` + case 'send': + return `send 5 ${symbol} to an address` + } +} + +export type SecondStepPress = + | { kind: 'prepare'; request: ChainActionRequest } + /** The button stays, since a wallet can change: this press does nothing. */ + | { kind: 'refused'; message: string } + +/** What pressing step 2's button does. `account` is the wallet step 1 was built for. */ +export function secondStepPress( + request: ChainActionRequest | undefined, + connected: string | undefined, + account: string +): SecondStepPress { + if (request === undefined) { + return { + kind: 'refused', + message: + 'What arrived is too small to move, so step 2 did not start. It stays in your wallet.', + } + } + const wrongWallet = accountMismatch(connected, account) + return wrongWallet !== undefined + ? { kind: 'refused', message: wrongWallet } + : { kind: 'prepare', request } +} + +export type StepMark = 'now' | 'waiting' | 'done' | 'stopped' + +/** How each step stands. `settled` is whether step 1 is known to have settled. */ +export function stepMarks(stage: SequenceStage, settled: boolean): [StepMark, StepMark] { + if (!settled) return stage === 'swap' ? ['now', 'waiting'] : ['stopped', 'stopped'] + if (stage === 'stranded' || stage === 'unsettled') return ['done', 'stopped'] + if (stage === 'followed') return ['done', 'done'] + return stage === 'measuring' ? ['done', 'waiting'] : ['done', 'now'] +} + +/** "cash out 5 SOL": a swap to USDC, then the cash-out. */ +export function cashoutSequence( + route: Extract +): ChainSequenceRequest { + return { + swap: { + kind: 'swap', + amount: route.amount, + ...(route.amountIsUsd === true ? { amountIsUsd: true as const } : {}), + tokenIn: route.token, + tokenOut: 'USDC', + venue: undefined, + }, + followOn: { + kind: 'cashout', + ...(route.currency !== undefined ? { currency: route.currency } : {}), + }, + } +} + +export type CurrencyStep = + | { kind: 'refuse'; message: string } + | { kind: 'run'; currency: CashoutCurrencyCode } + | { kind: 'ask'; choices: CashoutCurrency[] } + +/** + * What to do about the currency before any swap: one this chain can't pay is refused here, while + * the swap is still unsigned, since the form could only swap it for another afterwards. + */ +export function currencyStep( + named: CashoutCurrencyCode | undefined, + choices: CashoutCurrency[] +): CurrencyStep { + if (choices.length === 0) return { kind: 'refuse', message: NO_CASHOUTS } + if (named !== undefined) { + return choices.some((c) => c.code === named) + ? { kind: 'run', currency: named } + : { kind: 'refuse', message: unsupportedCurrencyReply(named, choices) } + } + const only = choices.length === 1 ? choices[0] : undefined + return only !== undefined + ? { kind: 'run', currency: only.code as CashoutCurrencyCode } + : { kind: 'ask', choices } +} + +const NOT_THE_AMOUNT = + 'usd|usdc|dollars?|bucks?|naira|ngn|kes|ksh|ugx|tzs|shillings?|eur|euros?|gbp|pounds?' + +/** A figure, what follows it in its turn, and everything said after it. */ +interface Figure { + /** Nothing for a figure that can't be read as one amount, which then matches no amount at all. */ + value: string | undefined + after: string + rest: string +} + +/** + * The figures that could be the amount, turn by turn. Money in another unit is not one, nor "5k", + * "5%" or "$5", and nor is what follows "of my" or "of the": "2 of my 5" says what is held, and if + * that leaves too little to tell, the request is refused. + */ +function figuresSaid(symbol: string, turns: readonly string[]): Figure[] { + // "5 USDC" is 5 of a token when the token is USDC, and a sum of money when it is another's. + const notAmount = NOT_THE_AMOUNT.split('|') + .filter((unit) => unit !== symbol.toLowerCase()) + .join('|') + const number = new RegExp( + String.raw`(?:^|[^\w.,$₦£€])(? + [...turn.matchAll(number)].map((match) => { + const after = turn.slice((match.index ?? 0) + match[0].length) + return { + value: figureValue(match[1] as string), + after, + rest: [after, ...spaced.slice(index + 1)].join(' '), + } + }) + ) +} + +/** A figure with no token of its own: "no make it 2", "flip 3 of it". */ +const BARE_FIGURE = /^\s*(?:$|[,.!?;:)]|of\s+(?:it|that|this|them)\b|please\b)/i + +/** + * A figure as typed, or nothing if its comma is not a thousands comma. In "1,5" or "0,500" a comma + * is a decimal point, and dropping it would read many times the amount, while reading it as a point + * is a guess, so neither is the amount. See `AMOUNT`. + */ +function figureValue(text: string): string | undefined { + const plain = text.replace(/,+$/, '') + if (plain.includes(',') && !/^[1-9]\d{0,2}(?:,\d{3})+(?:\.\d+)?$/.test(plain)) return undefined + return canonicalNumber(plain) +} + +function canonicalNumber(text: string): string { + const [whole = '', fraction = ''] = text.replace(/,/g, '').split('.') + const trimmed = fraction.replace(/0+$/, '') + const digits = whole.replace(/^0+(?=\d)/, '') || '0' + return trimmed === '' ? digits : `${digits}.${trimmed}` +} + +/** + * Whether the person themselves said this amount and this token. A model's text never sets what is + * signed, so a cash-out it wrote out runs only if the person's own words carry the same figures. + * The amount must be the last figure stated, so a correction wins over what it corrects and what + * is spent wins over what is held, and it must stand alone, so 15 is not 5, and "$5", "5 dollars", + * "5k" and "5%" are not 5 of a token. The token must stand alone too, and a chain's name reads as + * its token either way round, so "solana" is SOL. Only where it names the coin, though: "5 USDT on + * solana" did not say SOL, and a model must not get to read it so. Pass what the person typed, and + * none of the model's lines. + */ +export function sequenceWordsMatch( + instruction: { amount: string; token: string }, + turns: readonly string[] +): boolean { + const symbol = chainTokenWord(instruction.token) + const last = figuresSaid(symbol, turns).at(-1) + return ( + tokenSaid(instruction.token, turns) && + last?.value === canonicalNumber(instruction.amount) && + countStandsOverDollars(symbol, turns) + ) +} + +interface DollarFigure { + value: string | undefined + start: number + end: number +} + +/** Every figure in US dollars. Another country's dollars, R$ or CA$, are not these. */ +function dollarFigures(text: string): DollarFigure[] { + const number = String.raw`(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)` + const dollars = new RegExp( + String.raw`(?:(? { + const at = match.index ?? 0 + return { + value: figureValue((match[1] ?? match[2]) as string), + start: match[2] === undefined ? at : at + match[0].indexOf(match[2]), + end: at + match[0].length, + } + }) +} + +function byToken(token: string): RegExp { + return new RegExp( + String.raw`^\s*(?:(?:usd|dollars?)\s+)?(?:worth\s+)?(?:of\s+)?(?:my\s+|the\s+)?(?:${token})${WORD_END}`, + 'i' + ) +} + +/** + * With dollars said as well, a count is only a count if it names its token and comes last. In "$1 + * of SOL, no make it 2" the 2 corrects the dollars, and in "5 SOL if it is above $100" either figure + * could be taken for the amount, so the person is asked to say it in full and no model chooses. + */ +function countStandsOverDollars(symbol: string, turns: readonly string[]): boolean { + const joined = spacedTurns(symbol, turns).join('\n') + const money = dollarFigures(joined).at(-1) + if (money === undefined) return true + const plain = figuresSaid(symbol, [joined]).at(-1) + if (plain === undefined || money.start >= joined.length - plain.after.length) return false + return byToken(tokenPattern(symbol).token).test(plain.after) +} + +/** What may come right before a bare figure that corrects an earlier one. */ +const CORRECTING = + /(?:^|\n|\b(?:actually|no|nope|instead|rather|sorry|make it|change it to))[\s,:-]*$/i + +/** + * Whether the person themselves said this many dollars of this token: "$5 of SOL", "5 dollars worth + * of my SOL", "SOL worth $5". The same rules as a count of tokens, for the same reason. It must be + * the last figure stated, so a correction wins, and it must sit by the token given up, so "$5 of + * USDC to SOL" is not $5 of SOL. A bare figure counts only as a correction of dollars already said + * of that token, so "5 SOL if it is above $100" is not $100 of SOL. A token received is named after. + */ +export function dollarsSaid( + amount: string, + tokenIn: string, + tokenOut: string | undefined, + turns: readonly string[] +): boolean { + const symbol = chainTokenWord(tokenIn) + const { token } = tokenPattern(symbol) + const joined = spacedTurns(symbol, turns).join('\n') + const figures = dollarFigures(joined) + const last = figures.at(-1) + if (last === undefined || last.value !== canonicalNumber(amount)) return false + + const after = joined.slice(last.end) + const plain = figuresSaid(symbol, [joined]).at(-1) + if (plain !== undefined && joined.length - plain.after.length > last.end) return false + + const worth = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})\s+worth\s+(?:of\s+)?$`, 'i') + const named = (figure: DollarFigure): boolean => + byToken(token).test(joined.slice(figure.end)) || worth.test(joined.slice(0, figure.start)) + + if (named(last)) { + const follows = byToken(token).exec(after) + const rest = follows === null ? after : after.slice(follows[0].length) + return tokenOut === undefined || tokenSaid(tokenOut, [rest], true) + } + const correction = + figures.slice(0, -1).some(named) && + BARE_FIGURE.test(after) && + CORRECTING.test(joined.slice(0, last.start)) && + !tokenSaid(tokenIn, [after], true) + if (!correction) return false + return tokenOut === undefined || tokenOutFollows(token, tokenOut, joined) +} + +/** Whether the person said the figure of a cash-out the model wrote out, in whichever unit it is. */ +export function cashoutSaid( + route: Extract, + turns: readonly string[] +): boolean { + return route.amountIsUsd === true + ? dollarsSaid(route.amount, route.token, undefined, turns) + : sequenceWordsMatch(route, turns) +} + +function tokenPattern(symbol: string): { token: string; names: string[] } { + const names = chainNamesOf(symbol) + const token = [symbol, ...names].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + return { token, names } +} + +/** "5sol" read as "5 sol", and "$ 5" as "$5". */ +function spacedTurns(symbol: string, turns: readonly string[]): string[] { + const glued = new RegExp(String.raw`(\d)(${tokenPattern(symbol).token})${WORD_END}`, 'gi') + return turns.map((turn) => turn.replace(glued, '$1 $2').replace(/([$₦£€])\s+/g, '$1')) +} + +const WORD_END = String.raw`(?![A-Za-z0-9+]|\.[A-Za-z0-9])` + +/** + * Whether the person themselves named this token, alone as a word. A chain's name counts as its + * token only where it names the coin: not after "on", "in", "from" and the like. A token received, + * `destination`, may also be named "to solana" or "into solana". + */ +export function tokenSaid(token: string, turns: readonly string[], destination = false): boolean { + const symbol = chainTokenWord(token) + const { token: pattern, names } = tokenPattern(symbol) + const word = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])(?:${pattern})${WORD_END}`, 'i') + const prepositions = destination + ? 'on|in|from|at|via|using|through' + : 'on|in|from|to|into|at|via|using|through' + const place = + names.length === 0 + ? undefined + : new RegExp( + String.raw`\b(?:${prepositions})\s+(?:my\s+|the\s+)?(?:${names.join('|')})\b|\b(?:${names.join('|')})\s+(?:wallet|account|address|chain|network|blockchain)\b`, + 'gi' + ) + return spacedTurns(symbol, turns).some((turn) => + word.test(place === undefined ? turn : turn.replace(place, ' ')) + ) +} + +/** + * Whether the amount sits next to the token given up ("5 SOL" or "SOL 5", "5 of my SOL"), and, when + * a token received is given, that token is named after it. Read over all the person's turns in + * order, joined, so "swap some sol", "5", "to usdc" is one swap. The last figure said counts with + * no token of its own, "no make it 2", or as a correction, "actually 3 sol". Then the token received + * must be named after the token given up, and the token given up not after the figure, which would + * make "5 to sol from usdc" read backwards. The reverse of a swap is not it. + */ +export function swapOrderSaid( + amount: string, + tokenIn: string, + tokenOut: string | undefined, + turns: readonly string[] +): boolean { + const symbol = chainTokenWord(tokenIn) + const { token } = tokenPattern(symbol) + const joined = spacedTurns(symbol, turns).join(' ') + const number = String.raw`(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)` + const before = new RegExp( + String.raw`(?:^|[^\w.,$₦£€])${number}\s*(?:of\s+)?(?:my\s+|the\s+)?(?:${token})${WORD_END}`, + 'gi' + ) + const after = new RegExp( + String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})${WORD_END}[\s:=x]*${number}`, + 'gi' + ) + const wanted = canonicalNumber(amount) + const ends: number[] = [] + for (const match of joined.matchAll(before)) { + if (figureValue(match[1] as string) === wanted) ends.push((match.index ?? 0) + match[0].length) + } + for (const match of joined.matchAll(after)) { + if (figureValue(match[1] as string) === wanted) ends.push((match.index ?? 0) + match[0].length) + } + const last = figuresSaid(symbol, turns).at(-1) + const isLast = last?.value === wanted + const bare = isLast && BARE_FIGURE.test(last.after) && !tokenSaid(tokenIn, [last.rest], true) + if (tokenOut === undefined) return ends.length > 0 || (bare && tokenSaid(tokenIn, turns)) + if (ends.some((end) => tokenSaid(tokenOut, [joined.slice(end)], true))) return true + return (bare || (isLast && ends.length > 0)) && tokenOutFollows(token, tokenOut, joined) +} + +/** Whether, after some naming of the token given up, the token received is named. */ +function tokenOutFollows(token: string, tokenOut: string, joined: string): boolean { + const given = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})${WORD_END}`, 'gi') + return [...joined.matchAll(given)].some((match) => + tokenSaid(tokenOut, [joined.slice((match.index ?? 0) + match[0].length)], true) + ) +} + +/** + * The answer to "which currency?" when it was typed as a cash-out, "cash out to KES", while a swap + * waits on the question. Nothing for a request with its own amount, which is a new request. + */ +export function questionAnswerFrom( + question: { sequence?: unknown; answer?: string } | null, + route: CashoutRoute +): CashoutCurrencyCode | undefined { + if (question?.sequence === undefined || question.answer !== undefined) return undefined + return route.kind === 'open' && route.amount === undefined ? route.currency : undefined +} + +/** A question asked on one chain can't start a swap on another, whose follow-ons were not checked. */ +export function questionChainRefusal(asked: string, now: string): string | undefined { + return asked === now + ? undefined + : 'This was asked on another chain, so it did not start. Nothing was signed or sent.' +} + +export const FOLLOW_ON_UNCHECKED = + 'I could not check that the next step can finish, so the swap did not start. Nothing was signed or sent.' + +/** + * Asks the server whether a deposit, stake or send could run after this swap, while the swap is + * unsigned: its venues, what they take, and what only the chain can say about a receiver. Any answer + * but a clear yes refuses, so a swap never starts on a follow-on nobody checked. + */ +export async function readFollowOnRefusal( + chain: string, + request: ChainSequenceRequest, + account: string +): Promise { + const { followOn, swap } = request + if (followOn.kind === 'cashout') return undefined + const step = { + kind: followOn.kind, + tokenIn: swap.tokenOut, + ...(followOn.kind === 'send' ? { recipient: followOn.recipient } : {}), + ...(followOn.kind !== 'send' && followOn.venue !== undefined ? { venue: followOn.venue } : {}), + } + try { + const response = await fetch(`/api/chain/${chain}/follow-on`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account, step }), + signal: AbortSignal.timeout(READ_TIMEOUT_MS), + }) + const body = (await response.json()) as { ok?: unknown; error?: unknown } + if (response.status === 200 && body.ok === true) return undefined + return response.status === 422 && typeof body.error === 'string' + ? body.error + : FOLLOW_ON_UNCHECKED + } catch { + return FOLLOW_ON_UNCHECKED + } +} diff --git a/apps/dapp/lib/chains/sequence.ts b/apps/dapp/lib/chains/sequence.ts new file mode 100644 index 00000000..010a76a1 --- /dev/null +++ b/apps/dapp/lib/chains/sequence.ts @@ -0,0 +1,100 @@ +import type { CashoutCurrencyCode } from '../weave/parse-cashout' +import { sendRefusal } from './recipient' +import type { ChainActionRequest, ChainVenue, ChainWithActions } from './types' + +export type FollowOn = + | { kind: 'cashout'; currency?: CashoutCurrencyCode } + | { kind: 'supply' | 'stake'; venue?: string } + | { kind: 'send'; recipient: string } + +export interface ChainSequenceRequest { + swap: ChainActionRequest + followOn: FollowOn +} + +/** Where a swap can go on to a cash-out. Stellar has its own path, so it is not here. */ +export const SEQUENCE_CASHOUT_CHAINS: readonly ChainWithActions[] = ['solana', 'sui', 'avalanche'] + +const NOTHING_MOVED = 'Nothing was signed or sent.' + +/** Exact base units of a decimal, or nothing if it is not one or has more places than the token. */ +export function toBase(text: string, decimals: number): bigint | undefined { + if (!/^\d+(?:\.\d+)?$/.test(text)) return undefined + const [whole = '0', fraction = ''] = text.split('.') + if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals))) return undefined + const padded = fraction.slice(0, decimals).padEnd(decimals, '0') + return BigInt(whole + padded) +} + +export function fromBase(base: bigint, decimals: number): string { + const digits = base.toString().padStart(decimals + 1, '0') + const whole = digits.slice(0, digits.length - decimals) + const fraction = digits.slice(digits.length - decimals).replace(/0+$/, '') + return fraction === '' ? whole : `${whole}.${fraction}` +} + +/** What arrived between two readings of a balance, in whole base units, or nothing if it did not grow. */ +export function deliveredAmount( + before: string | undefined, + after: string | undefined, + decimals: number +): string | undefined { + if (before === undefined || after === undefined) return undefined + const a = toBase(before, decimals) + const b = toBase(after, decimals) + if (a === undefined || b === undefined || b <= a) return undefined + return fromBase(b - a, decimals) +} + +/** + * What to ask Weave for, leaving room for the 2% it may take over the order: + * floor(delivered / 1.02) to 2 places. Nothing for zero or for text that is not an amount. + */ +export function cashoutAmountFrom(delivered: string): string | undefined { + const places = delivered.includes('.') ? (delivered.split('.')[1] ?? '').length : 0 + const base = toBase(delivered, places) + if (base === undefined) return undefined + const hundredths = (base * BigInt(10000)) / (BigInt(102) * BigInt(10) ** BigInt(places)) + return hundredths === BigInt(0) ? undefined : fromBase(hundredths, 2) +} + +/** + * The sentence for a follow-on that cannot run on this chain, or nothing if it can. `tokenOut` is + * what the swap delivers, for a send, whose chain may not move that token. + */ +export function followOnRefusal( + chain: ChainWithActions, + chainName: string, + followOn: FollowOn, + venues: readonly Pick[], + tokenOut?: string +): string | undefined { + if (followOn.kind === 'cashout') { + if (chain === 'near') { + return `Weave's NEAR deposits are unavailable right now, so a cash-out can't run from NEAR. ${NOTHING_MOVED}` + } + if (!SEQUENCE_CASHOUT_CHAINS.includes(chain)) { + return `Cash-outs don't run on ${chainName}. ${NOTHING_MOVED}` + } + return undefined + } + if (followOn.kind === 'send') return sendRefusal(chain, tokenOut, followOn.recipient) + if (!venues.some((v) => v.kinds.includes(followOn.kind))) { + const can = followOn.kind === 'supply' ? 'take a deposit' : followOn.kind + return `Nothing on ${chainName} can ${can} yet. ${NOTHING_MOVED}` + } + return undefined +} + +/** After the swap went through and the next step cannot go on: what reached the wallet, never a guess. */ +export function strandedReply( + symbol: string, + delivered: string | undefined, + followOn: FollowOn +): string { + const paidOut = followOn.kind === 'cashout' ? ' Nothing was paid out.' : '' + if (delivered === undefined) { + return `The swap went through, but I could not confirm what reached your wallet, so the next step did not run. What arrived stays in your wallet. Check your wallet before doing anything else.${paidOut}` + } + return `The swap went through and about ${delivered} ${symbol} reached your wallet, but the next step did not run.${paidOut}` +} diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index 14575136..5786b161 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -27,10 +27,13 @@ export function latchBeforeSign(family: Family): boolean { 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.' +/** The end of what `afterSigning` says on a wallet that gave no answer. */ +export const START_AGAIN = 'If it did not go out, send a new message to start again.' + /** * 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. + * server checks the deadline before it sends signed bytes, but an EVM or NEAR wallet sends for + * itself, and a late deposit may be refunded instead of swapped. */ export function quoteExpiredBeforeSigning( prepared: Pick, @@ -38,7 +41,7 @@ export function quoteExpiredBeforeSigning( ): boolean { const deadline = prepared.context?.['deadline'] return ( - prepared.signing.family === 'evm' && deadline !== undefined && !(Date.parse(deadline) > now) + walletSends(prepared.signing.family) && deadline !== undefined && !(Date.parse(deadline) > now) ) } @@ -56,7 +59,7 @@ export function afterSigning(outcome: SignedOutcome, family: Family): AfterSigni if (step.next === 'check') { return { next: 'unknown', - error: `${CHECK_WALLET} If it did not go out, send a new message to start again.`, + error: `${CHECK_WALLET} ${START_AGAIN}`, } } return step @@ -260,6 +263,14 @@ export function noteSubmitAnswer( } } +/** A retyped "$5 of SOL" comes to a slightly different count once the price moves. */ +const SAME_AMOUNT_WITHIN = 0.03 + +function sameAmount(a: string, b: string): boolean { + const [x, y] = [Number(a), Number(b)] + return Math.abs(x - y) <= SAME_AMOUNT_WITHIN * Math.max(x, y) +} + /** The newest attempt at this same action inside the window, if there is one. */ export function earlierAttempt( storage: AttemptStorage | undefined, @@ -271,7 +282,7 @@ export function earlierAttempt( a.chain === asked.chain && accountMismatch(a.account, asked.account) === undefined && a.tokenIn.toUpperCase() === asked.tokenIn.toUpperCase() && - Number(a.amount) === Number(asked.amount) + sameAmount(a.amount, asked.amount) ) return matches[matches.length - 1] } diff --git a/apps/dapp/lib/chains/solana/balances.ts b/apps/dapp/lib/chains/solana/balances.ts new file mode 100644 index 00000000..c85fce48 --- /dev/null +++ b/apps/dapp/lib/chains/solana/balances.ts @@ -0,0 +1,159 @@ +import { isAddress } from '@solana/kit' + +import { + BALANCE_DEADLINE_MS, + ChainActionRefused, + type TokenBalance, + type WalletBalances, +} from '../types' +import { fromBaseUnits, SOLANA_TOKENS } from './tokens' + +const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com' +const TOKEN_PROGRAMS = [ + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +] +const SOL = SOLANA_TOKENS[0]! + +/** The node answered, but refused the call. Anything else, a timeout included, is not caught here. */ +class RpcRefused extends Error {} + +async function rpc(method: string, params: unknown[], signal: AbortSignal): Promise { + const url = process.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, + }) + if (!response.ok) throw new RpcRefused(`${method} answered ${response.status}`) + const body = (await response.json()) as { result?: unknown; error?: unknown } + if (body.error !== undefined || body.result === undefined) { + throw new RpcRefused(`${method} was refused`) + } + return body.result +} + +interface Held { + mint: string + amount: string + decimals: number +} + +function parsedInfo(data: unknown): Held | undefined { + const info = (data as { parsed?: { info?: unknown } } | undefined)?.parsed?.info as + | { mint?: unknown; tokenAmount?: { amount?: unknown; decimals?: unknown } } + | undefined + const { mint, tokenAmount } = info ?? {} + if ( + typeof mint !== 'string' || + typeof tokenAmount?.amount !== 'string' || + typeof tokenAmount.decimals !== 'number' + ) { + return undefined + } + return { mint, amount: tokenAmount.amount, decimals: tokenAmount.decimals } +} + +function entry(mint: string, base: bigint, decimals: number): TokenBalance { + const listed = SOLANA_TOKENS.find((t) => t.id === mint && t !== SOL) + return { + symbol: listed?.symbol ?? mint, + id: mint, + amount: fromBaseUnits(base.toString(), decimals), + decimals, + native: false, + listed: listed !== undefined, + } +} + +async function accountsOf( + account: string, + filter: { programId: string } | { mint: string }, + signal: AbortSignal +): Promise { + const result = (await rpc( + 'getTokenAccountsByOwner', + [account, filter, { encoding: 'jsonParsed', commitment: 'confirmed' }], + signal + )) as { value?: { account?: { data?: unknown } }[] } + if (!Array.isArray(result.value)) throw new RpcRefused('token accounts unreadable') + return result.value.map((item) => { + const info = parsedInfo(item.account?.data) + if (info === undefined) throw new RpcRefused('token account unreadable') + return info + }) +} + +/** Sums accounts per mint, since a wallet can hold one mint in several. */ +function sumByMint(held: Held[]): TokenBalance[] { + const totals = new Map() + for (const { mint, amount, decimals } of held) { + totals.set(mint, { base: (totals.get(mint)?.base ?? BigInt(0)) + BigInt(amount), decimals }) + } + return [...totals].map(([mint, { base, decimals }]) => entry(mint, base, decimals)) +} + +async function listAll(account: string, signal: AbortSignal): Promise { + const perProgram = await Promise.all( + TOKEN_PROGRAMS.map((programId) => accountsOf(account, { programId }, signal)) + ) + return sumByMint(perProgram.flat()) +} + +/** + * Each token in the table by its mint, which finds an account in either program and not only the + * associated one. A token that can't be read is left out and named, never shown as zero. + */ +async function readTable( + account: string, + signal: AbortSignal +): Promise<{ tokens: TokenBalance[]; unread: string[] }> { + const reads = await Promise.all( + SOLANA_TOKENS.filter((t) => t !== SOL).map(async (token) => { + try { + const held = await accountsOf(account, { mint: token.id }, signal) + const base = held.reduce((sum, h) => sum + BigInt(h.amount), BigInt(0)) + return { token, balance: entry(token.id, base, token.decimals) } + } catch (e) { + if (!(e instanceof RpcRefused)) throw e + return { token, balance: undefined } + } + }) + ) + return { + tokens: reads.flatMap((r) => (r.balance === undefined ? [] : [r.balance])), + unread: reads.filter((r) => r.balance === undefined).map((r) => r.token.symbol), + } +} + +/** Read at `confirmed`, so a figure is what the chain has agreed on, not what a node last saw. */ +export async function readSolanaBalances( + account: string, + signal: AbortSignal = AbortSignal.timeout(BALANCE_DEADLINE_MS) +): Promise { + if (!isAddress(account)) { + throw new ChainActionRefused( + 'That is not a Solana address. Connect a Solana wallet and try again.' + ) + } + const lamports = (await rpc('getBalance', [account, { commitment: 'confirmed' }], signal)) as { + value?: unknown + } + if (typeof lamports.value !== 'number') throw new RpcRefused('balance unreadable') + const native: TokenBalance = { + symbol: SOL.symbol, + id: SOL.id, + amount: fromBaseUnits(String(lamports.value), SOL.decimals), + decimals: SOL.decimals, + native: true, + listed: true, + } + try { + return { tokens: [native, ...(await listAll(account, signal))], complete: true } + } catch (e) { + if (!(e instanceof RpcRefused)) throw e + const { tokens, unread } = await readTable(account, signal) + return { tokens: [native, ...tokens], complete: false, unread } + } +} diff --git a/apps/dapp/lib/chains/solana/capabilities.ts b/apps/dapp/lib/chains/solana/capabilities.ts index 3c02d000..960d7a56 100644 --- a/apps/dapp/lib/chains/solana/capabilities.ts +++ b/apps/dapp/lib/chains/solana/capabilities.ts @@ -2,6 +2,6 @@ import type { ChainCapability } from '../explain' /** What actually works here, for telling someone who asked for something else. Client-safe. */ export const solanaCapabilities: ChainCapability[] = [ - { kind: 'swap', venue: 'Jupiter', symbol: 'SOL' }, + { kind: 'swap', venue: 'Jupiter', symbol: 'SOL', then: ['cashout', 'supply', 'send'] }, { kind: 'supply', venue: 'Jupiter Lend', symbol: 'USDC' }, ] diff --git a/apps/dapp/lib/chains/solana/jupiter-lend.ts b/apps/dapp/lib/chains/solana/jupiter-lend.ts index c0e96428..4fc852dd 100644 --- a/apps/dapp/lib/chains/solana/jupiter-lend.ts +++ b/apps/dapp/lib/chains/solana/jupiter-lend.ts @@ -18,6 +18,7 @@ import { type ChainVenue, type PreparedAction, type VenueQuote, + takesOnly, } from '../types' import { assertDepositAmount, @@ -212,6 +213,7 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV id: 'jupiter-lend', name: 'Jupiter Lend Earn', kinds: ['supply'], + assertCanFollow: takesOnly('Jupiter Lend', [...SUPPORTED]), quote, prepare, assertSafe: solanaAssertSafe(JUPITER_LEND_POLICY), diff --git a/apps/dapp/lib/chains/solana/kamino.ts b/apps/dapp/lib/chains/solana/kamino.ts index 140797e1..267b04c1 100644 --- a/apps/dapp/lib/chains/solana/kamino.ts +++ b/apps/dapp/lib/chains/solana/kamino.ts @@ -11,6 +11,7 @@ import { type ChainVenue, type PreparedAction, type VenueQuote, + takesOnly, } from '../types' import { assertDepositAmount, solanaAssertSafe } from './assert-safe' import { venueFetch } from './http' @@ -194,6 +195,7 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { id: 'kamino', name: 'Kamino Earn', kinds: ['supply'], + assertCanFollow: takesOnly('Kamino', ['USDC']), 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 9198ea62..88697c33 100644 --- a/apps/dapp/lib/chains/solana/lulo.ts +++ b/apps/dapp/lib/chains/solana/lulo.ts @@ -11,6 +11,7 @@ import { type ChainVenue, type PreparedAction, type VenueQuote, + takesOnly, } from '../types' import { assertDepositAmount, solanaAssertSafe } from './assert-safe' import { venueFetch } from './http' @@ -117,6 +118,7 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { id: 'lulo', name: 'Lulo Protected', kinds: ['supply'], + assertCanFollow: takesOnly('Lulo', ['USDC']), 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 e8fff1ac..22e5fc0a 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -1,5 +1,7 @@ import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' +import { createSendVenue, SEND_VENUE_ID } from '../send' import type { ChainModule, ChainVenue } from '../types' +import { readSolanaBalances } from './balances' import { createJupiterLendVenue } from './jupiter-lend' import { createJupiterSwapVenue } from './jupiter-swap' import { createKaminoVenue } from './kamino' @@ -23,6 +25,7 @@ export function buildSolanaVenues( createJupiterLendVenue({ env }), createKaminoVenue(), ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []), + ...(canIssueOneClick(env) ? [createSendVenue('solana')] : []), ] } @@ -30,7 +33,8 @@ export const solanaModule: ChainModule = { chain: 'solana', tokens: SOLANA_TOKENS, venues: buildSolanaVenues(), - defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend' }, + defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend', send: SEND_VENUE_ID }, priceUsd, submit: createSolanaSubmit({ deadlineMs: SUBMIT_DEADLINE_MS }), + balances: readSolanaBalances, } diff --git a/apps/dapp/lib/chains/sui/balances.ts b/apps/dapp/lib/chains/sui/balances.ts new file mode 100644 index 00000000..e5227dc3 --- /dev/null +++ b/apps/dapp/lib/chains/sui/balances.ts @@ -0,0 +1,142 @@ +import { fromBaseUnits } from '../solana/tokens' +import { + BALANCE_DEADLINE_MS, + ChainActionRefused, + type TokenBalance, + type WalletBalances, +} from '../types' +import { SUI_TOKENS } from './tokens' + +const GRAPHQL_URL = 'https://graphql.mainnet.sui.io/graphql' +const MAX_PAGES = 5 +const OWNER = /^0x[0-9a-fA-F]{64}$/ +const COIN_TYPE = /^0x[0-9a-fA-F]+::\w+::\w+$/ + +const BALANCES = + 'query($a:SuiAddress!,$c:String){address(address:$a){balances(first:50,after:$c){pageInfo{hasNextPage endCursor} nodes{coinType{repr} totalBalance}}}}' + +async function graphql( + query: string, + variables: Record, + signal: AbortSignal +): Promise { + const response = await fetch(GRAPHQL_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal, + }) + if (!response.ok) throw new Error(`Sui GraphQL answered ${response.status}`) + const body = (await response.json()) as { data?: unknown; errors?: unknown } + if (body.errors !== undefined || body.data === undefined || body.data === null) { + throw new Error('Sui GraphQL refused the query') + } + return body.data +} + +/** The padded `0x000...02::sui::SUI` is `0x2::sui::SUI` in the token table. */ +function normalise(coinType: string): string { + return coinType.replace(/^0x0+(?=[0-9a-fA-F])/, '0x') +} + +function isZero(base: string): boolean { + return /^0+$/.test(base) +} + +interface Held { + type: string + base: string +} + +interface BalancePage { + address?: { + balances?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: { coinType?: { repr?: string }; totalBalance?: string }[] + } + } | null +} + +async function readAll( + account: string, + signal: AbortSignal +): Promise<{ held: Held[]; more: boolean }> { + const held: Held[] = [] + let cursor: string | null = null + for (let page = 0; page < MAX_PAGES; page += 1) { + const data = (await graphql(BALANCES, { a: account, c: cursor }, signal)) as BalancePage + const balances = data.address?.balances + if (balances?.nodes === undefined) throw new Error('Sui balances unreadable') + for (const node of balances.nodes) { + if (typeof node.coinType?.repr !== 'string' || typeof node.totalBalance !== 'string') { + throw new Error('Sui balance unreadable') + } + held.push({ type: normalise(node.coinType.repr), base: node.totalBalance }) + } + if (balances.pageInfo?.hasNextPage !== true) return { held, more: false } + cursor = balances.pageInfo.endCursor ?? null + if (cursor === null) return { held, more: true } + } + return { held, more: true } +} + +/** Decimals from the chain's own metadata. A coin with none is left out, not guessed. */ +async function decimalsOf(types: string[], signal: AbortSignal): Promise> { + const wanted = types.filter((t) => COIN_TYPE.test(t)) + if (wanted.length === 0) return new Map() + const query = `query{${wanted + .map((t, i) => `a${i}:coinMetadata(coinType:${JSON.stringify(t)}){decimals}`) + .join(' ')}}` + const data = (await graphql(query, {}, signal)) as Record + const out = new Map() + wanted.forEach((type, i) => { + const decimals = data[`a${i}`]?.decimals + if (typeof decimals === 'number') out.set(type, decimals) + }) + return out +} + +export async function readSuiBalances( + account: string, + signal: AbortSignal = AbortSignal.timeout(BALANCE_DEADLINE_MS) +): Promise { + if (!OWNER.test(account)) { + throw new ChainActionRefused('That is not a Sui address. Connect a Sui wallet and try again.') + } + const { held, more } = await readAll(account, signal) + let complete = !more + const unlisted = held + .filter((h) => !SUI_TOKENS.some((t) => t.id === h.type) && !isZero(h.base)) + .map((h) => h.type) + const decimals = await decimalsOf(unlisted, signal) + const tokens: TokenBalance[] = [] + for (const { type, base } of held) { + const listed = SUI_TOKENS.find((t) => t.id === type) + if (listed !== undefined) { + tokens.push({ + symbol: listed.symbol, + id: listed.id, + amount: fromBaseUnits(base, listed.decimals), + decimals: listed.decimals, + native: listed.id === SUI_TOKENS[0]!.id, + listed: true, + }) + continue + } + if (isZero(base)) continue + const places = decimals.get(type) + if (places === undefined) { + complete = false + continue + } + tokens.push({ + symbol: type.split('::').at(-1) ?? type, + id: type, + amount: fromBaseUnits(base, places), + decimals: places, + native: false, + listed: false, + }) + } + return { tokens, complete } +} diff --git a/apps/dapp/lib/chains/sui/capabilities.ts b/apps/dapp/lib/chains/sui/capabilities.ts index f0628937..86446f58 100644 --- a/apps/dapp/lib/chains/sui/capabilities.ts +++ b/apps/dapp/lib/chains/sui/capabilities.ts @@ -2,7 +2,7 @@ import type { ChainCapability } from '../explain' /** What actually works here, for telling someone who asked for something else. Client-safe. */ export const suiCapabilities: ChainCapability[] = [ - { kind: 'swap', venue: 'Cetus', symbol: 'SUI' }, + { kind: 'swap', venue: 'Cetus', symbol: 'SUI', then: ['cashout', 'supply', 'send'] }, { kind: 'supply', venue: 'NAVI', symbol: 'USDC' }, { kind: 'stake', venue: 'SpringSui', symbol: 'SUI' }, ] diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts index a1d692f1..67111346 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,5 +1,7 @@ import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' +import { createSendVenue, SEND_VENUE_ID } from '../send' import type { ChainModule, ChainVenue } from '../types' +import { readSuiBalances } from './balances' import { cetusVenue } from './cetus' import { naviVenue } from './navi' import { priceFromLlama } from './price' @@ -15,6 +17,7 @@ export function buildSuiVenues( ...(canIssueOneClick(env) ? [oneClickOriginVenue('sui')] : []), naviVenue, springsuiVenue, + ...(canIssueOneClick(env) ? [createSendVenue('sui')] : []), ] } @@ -22,7 +25,8 @@ export const suiModule: ChainModule = { chain: 'sui', tokens: SUI_TOKENS, venues: buildSuiVenues(), - defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui' }, + defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui', send: SEND_VENUE_ID }, priceUsd: (token) => priceFromLlama(token), submit: (input) => submitSui(input), + balances: readSuiBalances, } diff --git a/apps/dapp/lib/chains/sui/navi.ts b/apps/dapp/lib/chains/sui/navi.ts index 868ffcb4..22010e36 100644 --- a/apps/dapp/lib/chains/sui/navi.ts +++ b/apps/dapp/lib/chains/sui/navi.ts @@ -12,6 +12,7 @@ import { type PreparedAction, type SigningPayload, type VenueQuote, + takesOnly, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { naviApy, poolNotes } from './navi-rules' @@ -93,6 +94,7 @@ export const naviVenue: ChainVenue = { id: 'navi', name: 'NAVI', kinds: ['supply'], + assertCanFollow: takesOnly('NAVI', ['USDC']), quote, prepare, assertSafe: (payload: SigningPayload, account: string) => diff --git a/apps/dapp/lib/chains/sui/springsui.ts b/apps/dapp/lib/chains/sui/springsui.ts index da172d22..09070be0 100644 --- a/apps/dapp/lib/chains/sui/springsui.ts +++ b/apps/dapp/lib/chains/sui/springsui.ts @@ -9,6 +9,7 @@ import { type PreparedAction, type SigningPayload, type VenueQuote, + takesOnly, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { @@ -102,6 +103,7 @@ export const springsuiVenue: ChainVenue = { id: 'springsui', name: 'SpringSui', kinds: ['stake'], + assertCanFollow: takesOnly('SpringSui', ['SUI']), quote, prepare, assertSafe: (payload: SigningPayload, account: string) => diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 081f448a..ce50abee 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -8,17 +8,24 @@ import type { ChainSlug } from '@intent/types' export type ChainWithActions = Extract -export type ChainActionKind = 'swap' | 'supply' | 'stake' +export type ChainActionKind = 'swap' | 'supply' | 'stake' | 'send' /** What the user asked for. Amounts are decimal text in display units. */ export interface ChainActionRequest { kind: ChainActionKind amount: string + /** + * `amount` is dollars, not tokens: "$5 of SOL". Only the chat's start of an action reads this, + * and it is swapped for a count of tokens there, so nothing past that point ever sees it. + */ + amountIsUsd?: true | undefined tokenIn: string /** Swaps only. */ tokenOut?: string | undefined /** A venue the user named. Absent means the chain's default for this kind. */ venue?: string | undefined + /** Sends only, exactly as typed. Where the money goes, so it is checked and never changed. */ + recipient?: string | undefined } export interface NearTransaction { @@ -136,8 +143,63 @@ export interface ChainVenue { ) => void /** Overrides the module's submit where a venue lands its own transactions. */ submit?: ((input: SubmitInput) => Promise) | undefined + /** + * Refuses a step this venue could not run after a swap, while the swap is still unsigned. Reads + * only: nothing is built. A venue without this can't follow a swap. + */ + assertCanFollow?: ((step: FollowOnStep, account: string) => Promise) | undefined +} + +/** A step that would follow a swap. The amount isn't known until the swap has delivered it. */ +export interface FollowOnStep { + kind: ChainActionKind + tokenIn: string + venue?: string | undefined + recipient?: string | undefined +} + +/** `assertCanFollow` for a venue that takes only these tokens. */ +export function takesOnly( + venueName: string, + symbols: readonly string[] +): NonNullable { + return async ({ tokenIn }) => { + const wanted = tokenIn.trim().toUpperCase() + if (symbols.some((symbol) => symbol.toUpperCase() === wanted)) return + throw new ChainActionRefused( + `${venueName} takes ${symbols.join(' or ')} here, not ${tokenIn}. Nothing was signed or sent.` + ) + } } +/** + * One token a wallet holds. Anything that measures a change in a holding must match on `id`, never + * on `symbol`: an unlisted token's symbol comes from its coin type or its contract, which anyone + * can name to copy a real one. + */ +export interface TokenBalance { + symbol: string + id: string + amount: string + decimals: number + native: boolean + /** In this chain's token table. An unlisted token can't be traded here yet. */ + listed: boolean +} + +export interface WalletBalances { + tokens: TokenBalance[] + /** False when some token the wallet may hold is missing from `tokens`. */ + complete: boolean + /** Only known tokens can be read on this chain, so an unlisted one is never shown. */ + knownOnly?: boolean | undefined + /** Symbols of listed tokens that could not be read, and so are left out rather than shown as zero. */ + unread?: string[] | undefined +} + +/** Longest a balance read may take, all calls together. */ +export const BALANCE_DEADLINE_MS = 15_000 + export interface ChainModule { chain: ChainWithActions tokens: readonly ChainToken[] @@ -147,6 +209,8 @@ export interface ChainModule { priceUsd: (token: ChainToken) => Promise /** Sends signed bytes, or records what a wallet already sent. */ submit: (input: SubmitInput) => Promise + /** Every token the account holds, read now. Throws on a malformed account (a refusal) or an unreadable chain. */ + balances: (account: string, signal?: AbortSignal) => Promise /** A sentence for a symbol this chain doesn't list but a person may well type, by upper-case symbol. */ tokenHints?: Record | undefined } diff --git a/apps/dapp/lib/chat/live-session.ts b/apps/dapp/lib/chat/live-session.ts new file mode 100644 index 00000000..2e51b789 --- /dev/null +++ b/apps/dapp/lib/chat/live-session.ts @@ -0,0 +1,186 @@ +import { SEND_VENUE_NAME } from '../chains/recipient' +import { accountMismatch } from '../weave/cashout-view' + +/** + * What was said in the chat, kept until New chat. + * + * The mounted chat survives leaving the page; this survives a reload. It is a + * transcript and nothing else: words and an explorer link, per chain and per + * wallet, with nothing that could be signed or acted on. + */ + +export interface LiveTurn { + role: 'user' | 'agent' + text: string + explorerUrl?: string + at: number +} + +const STORAGE_BASE = 'intent.chat.live.v1' +const MAX_TURNS = 200 +const MAX_TEXT = 2000 + +export function liveSessionKey(chain: string, account: string): string { + return `${STORAGE_BASE}:${chain}:${account}` +} + +/** The browser's storage, when it will hand it over. Private modes can refuse. */ +export function liveSessionStorage(): Storage | undefined { + try { + return globalThis.localStorage + } catch { + return undefined + } +} + +function httpsUrl(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + try { + return new URL(value).protocol === 'https:' ? value : undefined + } catch { + return undefined + } +} + +/** + * A bank account number typed into a cash-out line must not sit in storage, and + * one long paste must not use up the quota and silently stop later turns. + */ +function storedText(text: string): string { + return text.replace(/\d{8,}/g, (run) => `••••${run.slice(-4)}`).slice(0, MAX_TEXT) +} + +/** Rebuilt field by field, so nothing else in an entry can come through. */ +function toLiveTurn(value: unknown): LiveTurn | undefined { + if (typeof value !== 'object' || value === null) return undefined + const { role, text, at, explorerUrl } = value as Record + if (role !== 'user' && role !== 'agent') return undefined + if (typeof text !== 'string' || typeof at !== 'number' || !Number.isFinite(at)) return undefined + const link = httpsUrl(explorerUrl) + return { role, text: storedText(text), at, ...(link !== undefined ? { explorerUrl: link } : {}) } +} + +export function loadLiveSession( + storage: Pick | undefined, + chain: string, + account: string +): LiveTurn[] { + try { + const raw = storage?.getItem(liveSessionKey(chain, account)) + if (raw === null || raw === undefined) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed + .map(toLiveTurn) + .filter((turn): turn is LiveTurn => turn !== undefined) + .slice(-MAX_TURNS) + } catch { + return [] + } +} + +export function appendLiveTurn( + storage: Pick | undefined, + chain: string, + account: string, + turn: LiveTurn +): void { + const clean = toLiveTurn(turn) + if (clean === undefined) return + try { + const turns = [...loadLiveSession(storage, chain, account), clean].slice(-MAX_TURNS) + storage?.setItem(liveSessionKey(chain, account), JSON.stringify(turns)) + } catch { + // A full or refused store loses the transcript, never the chat. + } +} + +export function clearLiveSession( + storage: Pick | undefined, + chain: string, + account: string +): void { + try { + storage?.removeItem(liveSessionKey(chain, account)) + } catch { + // Nothing to do: the chat on screen is cleared either way. + } +} + +/** + * What a change of wallet does to the chat on screen. Another wallet must never + * see this one's chat, and resetting under an open signature would orphan it, so + * that waits. A wallet arriving or going away is the gate's business, not a switch. + */ +export function ownerChange( + previous: string | undefined, + next: string | undefined, + busy: boolean +): 'keep' | 'reset' | 'defer' { + if (previous === undefined || next === undefined) return 'keep' + if (accountMismatch(next, previous) === undefined) return 'keep' + return busy ? 'defer' : 'reset' +} + +export const WALLET_SWITCHED_NOTICE = + 'Your wallet changed. The payment under way here has to finish first.' +export const FINISH_SIGNATURE_NOTICE = + 'Finish or cancel the signature first, then continue the chat.' + +/** Notices about a payment answer nothing the person said, so a reload would show them alone. */ +export function recordableReply(text: string): boolean { + return text !== WALLET_SWITCHED_NOTICE && text !== FINISH_SIGNATURE_NOTICE +} + +const CARD_WITHOUT_DEADLINE_MS = 5 * 60_000 + +/** Whether a card still open when the person comes back has outlived what it was built on. */ +export function cardIsStale(card: { deadline?: string; shownAt: number }, now: number): boolean { + const deadline = card.deadline === undefined ? Number.NaN : Date.parse(card.deadline) + return Number.isNaN(deadline) ? now - card.shownAt > CARD_WITHOUT_DEADLINE_MS : now >= deadline +} + +/** + * The label of the bank list's search box, in `bank-picker.tsx`. That file is not + * ours to change, so a test reads it to keep the two in step. + */ +export const BANK_SEARCH_LABEL = 'Search banks' + +/** Turns read for one wallet are shown to that wallet only, even for the render after a switch. */ +export function ownedTurns( + saved: { account: string; turns: LiveTurn[] } | null, + account: string | undefined +): LiveTurn[] { + return saved !== null && saved.account === account ? saved.turns : [] +} + +/** + * Whether this address is the chat. `segment` is the path's first part, which on + * Stellar carries the network, so a chain's bare slug would miss `/stellar-testnet`. + */ +export function showChatOnPath(pathname: string, segment: string): boolean { + return pathname === `/${segment}/intents` || pathname === `/${segment}/intents/new` +} + +/** The one line a chain race leaves in the transcript, once it has an ending. */ +export function chainOutcomeSentence(outcome: { + phase: string + venueName?: string | undefined + error?: string | undefined +}): string | undefined { + const { phase, venueName, error } = outcome + if (venueName === undefined) return phase === 'failed' ? error : undefined + if (venueName === SEND_VENUE_NAME) { + if (phase === 'settled') return 'Sent from your wallet.' + if (phase === 'pending') { + return "This transfer may have gone through, so check your wallet's activity before trying again." + } + return phase === 'failed' ? (error ?? 'This could not be finished.') : undefined + } + if (phase === 'settled') return `Sent on ${venueName}.` + if (phase === 'pending') { + return `The transaction on ${venueName} may have gone through, so check your wallet's activity before trying again.` + } + if (phase === 'failed') return `On ${venueName}: ${error ?? 'This could not be finished.'}` + return undefined +} diff --git a/apps/dapp/lib/chat/orchestrate-client.ts b/apps/dapp/lib/chat/orchestrate-client.ts index 6f982ecf..7a8c4698 100644 --- a/apps/dapp/lib/chat/orchestrate-client.ts +++ b/apps/dapp/lib/chat/orchestrate-client.ts @@ -11,6 +11,8 @@ export interface ActReading { amountStated: boolean amountIsUsd: boolean followOn: FollowOnAction | null + /** Off Stellar only: a follow-on the server read and this chain can't honour. */ + followOnRefused?: { kind: 'lend' | 'offramp'; venue: string } style: 'market' | 'limit' | 'accumulate' | 'hedge' | 'rebalance' | 'route_liquidity' limitPriceUsd?: number /** What a bare quantity counts; absent when the model did not say. */ @@ -41,6 +43,24 @@ function strings(value: unknown): string[] { return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] } +/** The per-minute limit, or the daily cap, whose wait is hours. The server says how long. */ +async function rateLimited(res: Response): Promise { + const body = (await res.json().catch(() => null)) as { retryAfter?: unknown } | null + const wait = typeof body?.retryAfter === 'number' ? body.retryAfter : 0 + return { + outcome: 'unavailable', + reason: wait > 120 ? 'rate_limited_today' : 'rate_limited', + tokens: [], + } +} + +function refusedOf(value: unknown): Pick { + const refused = value as { kind?: unknown; venue?: unknown } | null | undefined + if (refused?.kind !== 'lend' && refused?.kind !== 'offramp') return {} + const venue = typeof refused.venue === 'string' ? refused.venue : '' + return { followOnRefused: { kind: refused.kind, venue } } +} + /** * Asks the server what a typed message is. * @@ -66,6 +86,7 @@ export async function fetchOrchestrate( ...(context.length > 0 ? { context } : {}), }), }) + if (res.status === 429) return await rateLimited(res) if (!res.ok) return UNREACHABLE const a = (await res.json()) as Record @@ -111,6 +132,7 @@ export async function fetchOrchestrate( amountStated: a['amountStated'] === true, amountIsUsd: a['amountIsUsd'] === true, followOn: (a['followOn'] as FollowOnAction | null | undefined) ?? null, + ...refusedOf(a['followOnRefused']), style, ...(a['amountOf'] === 'sent' || a['amountOf'] === 'received' ? { amountOf: a['amountOf'] } diff --git a/apps/dapp/lib/intent-read-policy.ts b/apps/dapp/lib/intent-read-policy.ts index 6f465868..292c33d1 100644 --- a/apps/dapp/lib/intent-read-policy.ts +++ b/apps/dapp/lib/intent-read-policy.ts @@ -1,3 +1,5 @@ +import { chainNamesOf } from './chains/parse-action' +import { chainModule } from './chains/registry' import { configuredLendingVenues } from './lend/venues' import { resolveVerifiedAsset } from './swap/asset-registry' @@ -77,3 +79,33 @@ export function unknownTickers(text: string): string[] { } return [...seen].slice(0, 3) } + +/** + * What a reading may name on a chain that has its own module, or nothing on Stellar and chains + * without one. Symbols are upper case, as a reading is checked in upper case: the chain's tokens, + * its coin and the words people use for the coin ("solana"), and venues that can take a deposit. + */ +export function chainReadPolicy( + chain: string +): { symbols: string[]; venues: string[] } | undefined { + const found = chainModule(chain) + if (found === undefined) return undefined + const symbols = found.tokens.flatMap((token) => [ + token.symbol.toUpperCase(), + ...chainNamesOf(token.symbol).map((name) => name.toUpperCase()), + ]) + const venues = found.venues.filter((v) => v.kinds.includes('supply')).map((v) => v.id) + return { symbols: [...new Set(symbols)], venues } +} + +/** + * The chain's sentence for a token the person wrote that it spells another way, such as USDT for + * Monad's USDT0, or nothing. For a message that could not be read, so they are told why. + */ +export function tokenHintIn(chain: string, text: string): string | undefined { + const hints = chainModule(chain)?.tokenHints + if (hints === undefined) return undefined + const words = new Set(text.toUpperCase().match(/[A-Z0-9]+/g) ?? []) + const symbol = Object.keys(hints).find((key) => words.has(key.toUpperCase())) + return symbol === undefined ? undefined : hints[symbol] +} diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index 6f4b134b..f06bbb4e 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -1,5 +1,8 @@ +import { SENDABLE } from '../chains/recipient' import { chainModule } from '../chains/registry' -import type { ChainActionKind } from '../chains/types' +import { SEQUENCE_CASHOUT_CHAINS } from '../chains/sequence' +import type { ChainActionKind, ChainWithActions } from '../chains/types' +import { cashOutPrompt } from '../weave/corridors' /** * What Intent does, written down once. @@ -34,6 +37,28 @@ const STELLAR_ONLY = [ 'Each trade is capped at $500 on mainnet.', ] +const NATIVE_NOT_SENT: Record = { + solana: "Native SOL can't be sent yet; USDC and the listed tokens can.", + sui: "Native SUI can't be sent yet; USDC and the listed tokens can.", + near: "Native NEAR can't be sent yet; USDC and the listed tokens can.", +} + +export const OWN_TOKEN: Record = { + solana: 'SOL', + sui: 'SUI', + near: 'NEAR', + avalanche: 'AVAX', + monad: 'MON', +} + +/** Solana, Sui and NEAR list every token a wallet holds. Avalanche and Monad can only read the ones Intent knows. */ +function balanceLine(chain: string): string { + const readsAll = chain === 'solana' || chain === 'sui' || chain === 'near' + return readsAll + ? 'Balance: ask what you hold and the wallet is listed token by token, every token it holds.' + : 'Balance: ask what you hold and the wallet is listed token by token, the tokens Intent knows on this chain.' +} + const NOTHING_RUNS = 'Nothing can be swapped, supplied or staked on this chain right now.' function bullets(lines: string[]): string { @@ -48,8 +73,8 @@ export function capabilitySheet(chain: string): string { 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. + // Off Stellar only what the chain's own venues can do is claimed: accumulate and hedge are + // Stellar's, and the chat reads swap, supply, stake, send and a swap followed by one of them. // 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 => @@ -66,5 +91,41 @@ export function capabilitySheet(chain: string): string { `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]) + const sendable = SENDABLE[chain as ChainWithActions] + if (offered('send') && sendable !== undefined) { + lines.push( + `Send: send ${sendable.join(', ')} to an address on this chain. You review it and sign in your wallet. ${NATIVE_NOT_SENT[chain] ?? ''}`.trim() + ) + } + if (lines.length === 0) return bullets([NOTHING_RUNS]) + + if (offered('swap')) { + const own = OWN_TOKEN[chain] ?? 'SOL' + const cashesOut = + (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) && cashOutPrompt(chain) !== '' + if (cashesOut) { + lines.push( + `Swap then cash out: swap into USDC and pay it to a bank or mobile money account as one request, for example "swap 5 ${own} to USDC then cash out". You sign the swap, then start the cash-out yourself.` + ) + } + if (offered('supply')) { + lines.push( + `Swap then deposit: after a swap, put what arrived into a lending or earn venue, for example "swap 5 ${own} to USDC then deposit". You sign the swap, then the deposit.` + ) + } + if (offered('send')) { + lines.push( + `Swap then send: after a swap, send what arrived to an address you type, for example "swap 5 ${own} to USDC then send to an address". The address must be typed by you in full.` + ) + } + if (chain === 'near') { + lines.push( + "Cash out does not run from NEAR: Weave's NEAR deposits aren't working yet. Stake after a swap is not available on NEAR, since a swap to NEAR can arrive as NEAR or wNEAR, so stake what arrives as a second request." + ) + } else if (chain === 'monad') { + lines.push('Cash out and deposit after a swap do not run on Monad.') + } + } + lines.push('Limit orders are not available off Stellar.', balanceLine(chain)) + return bullets(lines) } diff --git a/apps/dapp/lib/orchestrate/orchestrate.ts b/apps/dapp/lib/orchestrate/orchestrate.ts index 340cdbdd..f157e3fd 100644 --- a/apps/dapp/lib/orchestrate/orchestrate.ts +++ b/apps/dapp/lib/orchestrate/orchestrate.ts @@ -1,7 +1,8 @@ +import { chainModule } from '../chains/registry' import { cashOutPrompt } from '../weave/corridors' import { tidyProse } from '../chat/prose' import { intentModels, type IntentModel } from '../llm/intent-models' -import { capabilitySheet } from './capabilities' +import { OWN_TOKEN, capabilitySheet } from './capabilities' import { READ_INTENT_PROPERTIES, READ_INTENT_REQUIRED, @@ -174,45 +175,91 @@ const ORCHESTRATE_TOOL = { }, } -const BASE_PROMPT = [ - 'You are the front door of Intent, an app where people state what they want done with their money on the Stellar network and trading agents compete to do it. Call orchestrate exactly once.', - "Decide by what the person MEANS, not by which words they used: 'flip', 'turn into', 'get me some', 'move my lumens to dollars' are all trades.", - "Use outcome 'out_of_scope' for anything unrelated to that work — exercise, weather, recipes, general chat. Do not try to stretch an unrelated message into a trade.", - "Use outcome 'balance' when the person asks to see what is in their wallet: their balance, their tokens or assets, how much they have. Leave reply empty; the app lists the wallet itself. A question about the price of an asset is not a balance request.", - "Use outcome 'help' when the person asks what they can do, what Intent is, how to get started, or asks you to explain something Intent does.", - 'For help, write reply yourself, in your own words, as a person would in a conversation. Answer exactly what they asked: if they ask about one thing, explain that one thing rather than listing everything.', - 'Write reply in flowing paragraphs, the way a person writes a chat message: two or three sentences each, separated by a blank line. Weave several things into sentences rather than listing them; group related abilities together ("you can swap, send, or lend what you hold"). Use a bulleted list only when the person explicitly asks for a list or for steps. Never use an em dash or an en dash; use a period, a comma or a colon instead.', - 'The facts about Intent below are what is true, not wording to copy. If what they ask for is not in the facts, say plainly that Intent does not do it. Never promise a feature that is not listed.', - 'Earlier turns of this conversation are given, including your own replies. Never repeat an earlier reply. If they ask you to explain better, or do not understand, explain it a different way: simpler, with a concrete example, or from another angle.', - 'For an out_of_scope message, leave reply empty: the app states the boundary and lists what Intent does. Do not comment on the message, give advice, or suggest a trade because of it. The one exception is a message that expresses genuine distress, where reply is a single short caring sentence and nothing more.', - "Use outcome 'ask' whenever you cannot tell what they want done or something it needs is missing. Ask one question, in plain words. Never guess, and never pass unclear words along as an instruction.", - "Use outcome 'act' as soon as the request is complete. Do not ask for confirmation of something already clear.", - 'A trade needs the asset sold, the asset received, and a size (an amount of the asset, or a dollar amount).', - 'If the person names only one side of a trade, ask what the other side is, in one question. Never leave tokenIn or tokenOut empty in an act.', - 'Do NOT ask for a bank, an account number or an account name: a form collects those afterwards.', - 'Never ask about price, slippage, venue or route: the agents decide those.', - 'Never ask something the user already answered, or that the earlier conversation already settles.', - 'Earlier turns of the chat are given as context. A request like "send half of it to deon.xlm" refers to them:', - 'write it out in instruction ("Send 1 USDC to deon.xlm") using the amounts from the context.', - 'If you cannot work out what "it" is from the context, ask.', - 'Correct obvious typos when you write the instruction: "edge 5 USDC exposure" means "hedge 5 USDC exposure".', - 'Hedging an asset means moving that exposure into another asset. If the request says hedge or reduce exposure to something', - 'and does not say what to move into, ask one question for it and offer XLM as an example.', - "For 'act', fill the reading fields. action is 'swap' whenever a trade is asked for, 'supply' when an asset already held goes into a lending pool with no trade, 'borrow' and 'repay' for loans, and 'offramp' for sending USDC already held to a bank.", - "followOn is 'lend' ONLY when the user asks for the proceeds to be supplied, lent or put to work in a lending pool, and 'offramp' ONLY when they ask for the proceeds to go to their bank or to fiat.", - 'Joining two ASSETS with "and" is NOT a follow-on: "buy XLM and USDC" is one purchase of two things.', - 'Venue names are often misspelled. "Blende", "blnd" and "blend protocol" all mean Blend.', - "Use followOnVenue 'blend' when Blend is named or clearly implied, 'defindex' when DeFindex is, 'moneygram' or 'testanchor' for those anchors, otherwise an empty string.", - 'amountStated is false when the user named no size; set amountUsd to 0 in that case.', - 'When a size is named, amountUsd holds the number the user wrote, in whichever unit they used: "1 XLM" is amountUsd 1 with amountIsUsd false, and "$5 of XLM" is amountUsd 5 with amountIsUsd true. It is never 0 when amountStated is true.', - 'For anything other than act, leave the reading fields at their defaults.', - 'facts must never contain an account number or a person’s name.', -].join(' ') +function basePrompt(chain: string): string { + const st = chain === 'stellar' + const own = OWN_TOKEN[chain] ?? 'USDT' + return [ + st + ? 'You are the front door of Intent, an app where people state what they want done with their money on the Stellar network and trading agents compete to do it. Call orchestrate exactly once.' + : 'You are the front door of Intent, an app where people state what they want done with their money and trading agents compete to do it. Call orchestrate exactly once.', + st + ? "Decide by what the person MEANS, not by which words they used: 'flip', 'turn into', 'get me some', 'move my lumens to dollars' are all trades." + : "Decide by what the person MEANS, not by which words they used: 'flip', 'turn into', 'get me some', 'move my coins to dollars' are all trades.", + "Use outcome 'out_of_scope' for anything unrelated to that work — exercise, weather, recipes, general chat. Do not try to stretch an unrelated message into a trade.", + "Use outcome 'balance' when the person asks to see what is in their wallet: their balance, their tokens or assets, how much they have. Leave reply empty; the app lists the wallet itself. A question about the price of an asset is not a balance request.", + "Use outcome 'help' when the person asks what they can do, what Intent is, how to get started, or asks you to explain something Intent does.", + 'For help, write reply yourself, in your own words, as a person would in a conversation. Answer exactly what they asked: if they ask about one thing, explain that one thing rather than listing everything.', + 'Write reply in flowing paragraphs, the way a person writes a chat message: two or three sentences each, separated by a blank line. Weave several things into sentences rather than listing them; group related abilities together ("you can swap, send, or lend what you hold"). Use a bulleted list only when the person explicitly asks for a list or for steps. Never use an em dash or an en dash; use a period, a comma or a colon instead.', + 'The facts about Intent below are what is true, not wording to copy. If what they ask for is not in the facts, say plainly that Intent does not do it. Never promise a feature that is not listed.', + 'Earlier turns of this conversation are given, including your own replies. Never repeat an earlier reply. If they ask you to explain better, or do not understand, explain it a different way: simpler, with a concrete example, or from another angle.', + 'For an out_of_scope message, leave reply empty: the app states the boundary and lists what Intent does. Do not comment on the message, give advice, or suggest a trade because of it. The one exception is a message that expresses genuine distress, where reply is a single short caring sentence and nothing more.', + "Use outcome 'ask' whenever you cannot tell what they want done or something it needs is missing. Ask one question, in plain words. Never guess, and never pass unclear words along as an instruction.", + "Use outcome 'act' as soon as the request is complete. Do not ask for confirmation of something already clear.", + 'A trade needs the asset sold, the asset received, and a size (an amount of the asset, or a dollar amount).', + 'If the person names only one side of a trade, ask what the other side is, in one question. Never leave tokenIn or tokenOut empty in an act.', + 'Do NOT ask for a bank, an account number or an account name: a form collects those afterwards.', + 'Never ask about price, slippage, venue or route: the agents decide those.', + 'Never ask something the user already answered, or that the earlier conversation already settles.', + st + ? 'Earlier turns of the chat are given as context. A request like "send half of it to deon.xlm" refers to them:' + : 'Earlier turns of the chat are given as context. A request like "send half of it to the address I gave" refers to them:', + st + ? 'write it out in instruction ("Send 1 USDC to deon.xlm") using the amounts from the context.' + : 'write it out in instruction ("Send 1 USDC to ADDRESS") using the amounts from the context and the address exactly as the person typed it.', + 'If you cannot work out what "it" is from the context, ask.', + 'Correct obvious typos when you write the instruction: "edge 5 USDC exposure" means "hedge 5 USDC exposure".', + 'Hedging an asset means moving that exposure into another asset. If the request says hedge or reduce exposure to something', + st + ? 'and does not say what to move into, ask one question for it and offer XLM as an example.' + : 'and does not say what to move into, ask one question for it and offer USDC as an example.', + "For 'act', fill the reading fields. action is 'swap' whenever a trade is asked for, 'supply' when an asset already held goes into a lending pool with no trade, 'borrow' and 'repay' for loans, and 'offramp' for sending USDC already held to a bank.", + st + ? "followOn is 'lend' ONLY when the user asks for the proceeds to be supplied, lent or put to work in a lending pool, and 'offramp' ONLY when they ask for the proceeds to go to their bank or to fiat." + : "followOn is 'lend' ONLY when the user asks for the proceeds to be deposited or put to work, and 'offramp' ONLY when they ask for the proceeds to go to their bank or to cash.", + st + ? 'Joining two ASSETS with "and" is NOT a follow-on: "buy XLM and USDC" is one purchase of two things.' + : `Joining two ASSETS with "and" is NOT a follow-on: "buy ${own} and USDC" is one purchase of two things.`, + st + ? 'Venue names are often misspelled. "Blende", "blnd" and "blend protocol" all mean Blend.' + : 'Venue names are often misspelled. Read each as the venue the person meant.', + st + ? "Use followOnVenue 'blend' when Blend is named or clearly implied, 'defindex' when DeFindex is, 'moneygram' or 'testanchor' for those anchors, otherwise an empty string." + : 'Use followOnVenue only for a deposit venue the person named, otherwise an empty string.', + 'amountStated is false when the user named no size; set amountUsd to 0 in that case.', + st + ? 'When a size is named, amountUsd holds the number the user wrote, in whichever unit they used: "1 XLM" is amountUsd 1 with amountIsUsd false, and "$5 of XLM" is amountUsd 5 with amountIsUsd true. It is never 0 when amountStated is true.' + : `When a size is named, amountUsd holds the number the user wrote, in whichever unit they used: "1 ${own}" is amountUsd 1 with amountIsUsd false, and "$5 of ${own}" is amountUsd 5 with amountIsUsd true. It is never 0 when amountStated is true.`, + 'For anything other than act, leave the reading fields at their defaults.', + 'facts must never contain an account number or a person’s name.', + ].join(' ') +} + +/** + * What differs off Stellar, where the opening line and the follow-ons above are written for + * Stellar. The facts below list what this chain can run, and the app checks everything signed + * against what the person typed, so an address or an amount is copied and never filled in. + */ +function offStellarPrompt(chain: string): string { + const own = OWN_TOKEN[chain] ?? 'USDT' + return [ + 'The person is on the chain named in the facts below, not on Stellar.', + "followOn is 'lend' only for a deposit that the facts list under Swap then deposit, and 'offramp' only for a cash-out that the facts list under Swap then cash out. Anything else that follows a swap, such as a send or a stake, is not a followOn: write the whole request in instruction.", + `When a swap is followed by something, write the instruction as one sentence like "Swap 5 ${own} to USDC, then send to ADDRESS", with every amount, token and address copied character for character from what the person typed. Never invent, shorten or correct an address, and never add an amount they did not give.`, + ].join(' ') +} /** Only where a cash-out exists; elsewhere naming it would promise what the chain lacks. */ function systemPrompt(chain: string): string { const cashOut = cashOutPrompt(chain) - return cashOut === '' ? BASE_PROMPT : `${BASE_PROMPT} ${cashOut}` + const prompt = basePrompt(chain) + const base = cashOut === '' ? prompt : `${prompt} ${cashOut}` + if (chain === 'stellar') return base + const symbols = chainModule(chain)?.tokens.map((token) => token.symbol) + const tokens = + symbols === undefined + ? '' + : ` The tokens on this chain are ${symbols.join(', ')}; name only these in tokenIn and tokenOut, and write a chain's name such as "solana" as its coin.` + return `${base} ${offStellarPrompt(chain)}${tokens}` } /** Long enough for any sentence a person types here, short enough to bound what is passed on. */ diff --git a/apps/dapp/lib/parse-intent-llm.ts b/apps/dapp/lib/parse-intent-llm.ts index fe9da8a5..dd37160b 100644 --- a/apps/dapp/lib/parse-intent-llm.ts +++ b/apps/dapp/lib/parse-intent-llm.ts @@ -181,6 +181,11 @@ export interface LlmReadIntent { */ amountIsUsd: boolean followOn: FollowOnAction | null + /** + * A follow-on the model read that this chain can't honour, when the caller asked to be told. + * `venue` is what the model named, or empty. + */ + followOnRefused?: { kind: 'lend' | 'offramp'; venue: string } } export interface ReadIntentOptions { @@ -195,6 +200,11 @@ export interface ReadIntentOptions { allowedVenues?: string[] /** Anchors that withdraw to fiat on this chain. Empty means none. */ allowedAnchors?: string[] + /** + * Off Stellar: say which follow-on could not be honoured rather than dropping it, since a swap + * must not start without it, and read a venue's name whatever its spaces and hyphens. + */ + reportRefusedFollowOn?: boolean } export interface ToolArguments { @@ -342,6 +352,10 @@ export function readingFromArguments( // trading on it would size the trade wrong rather than not at all. if (amountStated && amountUsd <= 0) return null + const report = options.reportRefusedFollowOn === true + const followOn = followOnOf(args, options.allowedVenues, options.allowedAnchors, report) + const refused = report && followOn === null ? refusedFollowOn(args) : undefined + return { action, tokenIn, @@ -349,10 +363,18 @@ export function readingFromArguments( amountUsd: amountStated ? amountUsd : 0, amountStated, amountIsUsd: args.amountIsUsd === true, - followOn: followOnOf(args, options.allowedVenues, options.allowedAnchors), + followOn, + ...(refused !== undefined ? { followOnRefused: refused } : {}), } } +/** The follow-on asked for, once `followOnOf` found it can't be honoured. */ +function refusedFollowOn(args: ToolArguments): LlmReadIntent['followOnRefused'] { + if (args.followOn !== 'lend' && args.followOn !== 'offramp') return undefined + const venue = typeof args.followOnVenue === 'string' ? args.followOnVenue.trim() : '' + return { kind: args.followOn, venue } +} + function toolArgumentsOf(payload: unknown): ToolArguments | null { try { const choices = (payload as { choices?: { message?: { tool_calls?: unknown } }[] }).choices @@ -393,10 +415,14 @@ function symbolOf(value: unknown, allowed: string[] | undefined): string | undef function followOnOf( args: ToolArguments, allowedVenues: string[] | undefined, - allowedAnchors: string[] | undefined + allowedAnchors: string[] | undefined, + anySpacing: boolean ): FollowOnAction | null { const named = typeof args.followOnVenue === 'string' ? args.followOnVenue.trim().toLowerCase() : '' + const plain = (name: string): string => name.replace(/[\s-]/g, '') + const listed = (list: string[]): string | undefined => + anySpacing ? list.find((id) => plain(id) === plain(named)) : list.find((id) => id === named) if (args.followOn === 'offramp') { const anchors = allowedAnchors ?? [] @@ -405,7 +431,8 @@ function followOnOf( const first = anchors[0] return first === undefined ? null : { kind: 'offramp', venue: first } } - return anchors.includes(named) ? { kind: 'offramp', venue: named } : null + const anchor = listed(anchors) + return anchor !== undefined ? { kind: 'offramp', venue: anchor } : null } if (args.followOn !== 'lend') return null @@ -420,5 +447,6 @@ function followOnOf( return only === undefined ? null : { kind: 'lend', venue: only } } - return venues.includes(named) ? { kind: 'lend', venue: named, venueNamed: true } : null + const venue = listed(venues) + return venue !== undefined ? { kind: 'lend', venue, venueNamed: true } : null } diff --git a/apps/dapp/lib/server/rate-limit.ts b/apps/dapp/lib/server/rate-limit.ts index df852e07..9612e2be 100644 --- a/apps/dapp/lib/server/rate-limit.ts +++ b/apps/dapp/lib/server/rate-limit.ts @@ -19,7 +19,14 @@ import { getPool, withTimeout, type QueryFn } from './db' * and is then quiet until the process restarts. */ -export type RateLimitFamily = 'build' | 'submit' | 'compete' | 'resolve' | 'standing' | 'auth' +export type RateLimitFamily = + | 'build' + | 'read' + | 'submit' + | 'compete' + | 'resolve' + | 'standing' + | 'auth' export interface RateLimitWindow { limit: number @@ -35,6 +42,8 @@ export interface RateLimitWindow { */ export const RATE_LIMIT_DEFAULTS: Readonly> = { build: { limit: 30, windowSeconds: 60 }, + // Balance reads, which a measure after a swap repeats and must not spend from what builds. + read: { limit: 120, windowSeconds: 60 }, submit: { limit: 10, windowSeconds: 60 }, // Four a minute, not ten: compete is the one family where every request // is several paid model calls (DeepSeek, Claude, up to two OpenRouter diff --git a/apps/dapp/lib/wallet/address.ts b/apps/dapp/lib/wallet/address.ts index 22e07289..4f8fcb98 100644 --- a/apps/dapp/lib/wallet/address.ts +++ b/apps/dapp/lib/wallet/address.ts @@ -2,9 +2,9 @@ const STELLAR = /^G[A-Z2-7]{55}$/ const EVM = /^0x[0-9a-fA-F]{40}$/ const SUI = /^0x[0-9a-fA-F]{64}$/ const SOLANA = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ -const NEAR_IMPLICIT = /^[0-9a-f]{64}$/ +export const NEAR_IMPLICIT = /^[0-9a-f]{64}$/ /** Named NEAR accounts always have a dot once they are under a top-level account like `.near`. */ -const NEAR_NAMED = /^(?:[a-z0-9]+(?:[-_][a-z0-9]+)*\.)+[a-z0-9]+(?:[-_][a-z0-9]+)*$/ +export const NEAR_NAMED = /^(?:[a-z0-9]+(?:[-_][a-z0-9]+)*\.)+[a-z0-9]+(?:[-_][a-z0-9]+)*$/ /** * Whether a value has the shape of an address on a chain the app connects to. diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index 3ab144cc..d702fa8c 100644 --- a/apps/dapp/lib/weave/cashout-view.ts +++ b/apps/dapp/lib/weave/cashout-view.ts @@ -336,7 +336,10 @@ export function cashoutChoices( return chain === 'stellar' ? [NAIRA, ...weave.filter((c) => c.code !== 'NGN')] : [...weave] } -export function unsupportedTokenReply(token: string): string { +export function unsupportedTokenReply(token: string, chain: string): string { + if (chain === 'near') { + return `Cash-outs take USDC, not ${token}, and they can't run from NEAR right now because Weave's NEAR deposits are unavailable.` + } return `Cash-outs take USDC, not ${token}. Swap your ${token} to USDC first, then cash out.` } diff --git a/apps/dapp/lib/weave/currency-flag.ts b/apps/dapp/lib/weave/currency-flag.ts new file mode 100644 index 00000000..ecec424e --- /dev/null +++ b/apps/dapp/lib/weave/currency-flag.ts @@ -0,0 +1,20 @@ +const FLAGS: Readonly> = { + NGN: { src: '/flags/ng.svg', country: 'Nigeria' }, + KES: { src: '/flags/ke.svg', country: 'Kenya' }, + UGX: { src: '/flags/ug.svg', country: 'Uganda' }, + TZS: { src: '/flags/tz.svg', country: 'Tanzania' }, +} + +/** The country's flag for a payout currency, from the local files in public/flags. */ +export function currencyFlag(code: string): { src: string; country: string } | undefined { + return FLAGS[code] +} + +/** What to call the places a currency pays out to, from the kinds of institution it lists. */ +export function payoutWord( + types: readonly string[] +): 'bank' | 'mobile money provider' | 'bank or mobile money' { + if (types.length > 0 && types.every((t) => t === 'mobile_money')) return 'mobile money provider' + if (types.length > 0 && types.every((t) => t === 'bank')) return 'bank' + return 'bank or mobile money' +} diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index 0ddf5480..04d7cde2 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -73,7 +73,9 @@ function notOneTransfer(token: ChainToken): string { } const RpcAccount = z.object({ - result: z.object({ value: z.object({ owner: z.string() }).nullable() }), + result: z.object({ + value: z.object({ owner: z.string(), executable: z.boolean().optional() }).nullable(), + }), }) const RpcBlockhash = z.object({ @@ -290,14 +292,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( +export async function assertDepositIsNotTokenAccount( depositAddress: string, - token: ChainToken + token: ChainToken, + /** For an address a person typed. A program holds no key either, so what it is sent is stranded. */ + options: { refusePrograms?: boolean } = {} ): 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.') + if (options.refusePrograms === true && reply.data.result.value?.executable === true) { + refuse(`That address is a program, and ${token.symbol} sent there would be stuck.`) + } const owner = reply.data.result.value?.owner if (owner === TOKEN_PROGRAM || owner === TOKEN_2022_PROGRAM) { refuse( diff --git a/apps/dapp/lib/weave/orders.ts b/apps/dapp/lib/weave/orders.ts index b0bf9332..f5906efa 100644 --- a/apps/dapp/lib/weave/orders.ts +++ b/apps/dapp/lib/weave/orders.ts @@ -371,8 +371,7 @@ export async function getWeaveOrderRepo(): Promise { return repo } -/** Above this, an order is refused rather than offered. */ -export const MAX_COST_PERCENT = 5 +export { MAX_COST_PERCENT } from './types' function canonicalDecimal(text: string): string | undefined { const match = /^(\d+)(?:\.(\d+))?$/.exec(text) diff --git a/apps/dapp/lib/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index 21dbf64c..aac48fc8 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -1,3 +1,5 @@ +import { chainTokenWord } from '../chains/parse-action' +import { SEQUENCE_CASHOUT_CHAINS } from '../chains/sequence' import { isCashoutChain } from './types' export type CashoutCurrencyCode = 'NGN' | 'KES' | 'UGX' | 'TZS' @@ -10,7 +12,7 @@ export interface CashoutRequest { export type CashoutRead = | CashoutRequest - | { unsupportedToken: string; currency?: CashoutCurrencyCode } + | { unsupportedToken: string; amount?: string; dollars?: string; currency?: CashoutCurrencyCode } | { unsupportedCurrency: string } export type CashoutRoute = @@ -19,6 +21,15 @@ export type CashoutRoute = /** No currency was named, and a person's country is not something to assume. */ | { kind: 'ask'; amount?: string } | { kind: 'token'; token: string } + /** A swap to USDC then a cash-out, on a chain that can finish it. */ + | { + kind: 'sequence' + token: string + amount: string + /** `amount` is dollars of the token, to be sized at a live price before anything runs. */ + amountIsUsd?: true + currency?: CashoutCurrencyCode + } | { kind: 'currency'; currency: string } const CURRENCY_WORDS: Record = { @@ -131,10 +142,25 @@ function hasOwn(table: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(table, key) } -function isFiatWord(word: string): boolean { +/** Whether this word or phrase names a currency, paid here or not. Lower case, single spaces. */ +export function isFiatWord(word: string): boolean { return hasOwn(CURRENCY_WORDS, word) || hasOwn(OTHER_FIAT, word) } +/** + * The one currency the person's own words name, or nothing if they name none or more than one, so + * that the question is asked instead. Only words that mean a currency alone count: "my nigerian + * friend" says nothing about a payout. + */ +export function currencyNamedIn(turns: readonly string[]): CashoutCurrencyCode | undefined { + const text = turns.join(' ').toLowerCase().replace(/\s+/g, ' ') + const found = new Set() + for (const [word, code] of Object.entries(CURRENCY_WORDS)) { + if (new RegExp(String.raw`(? + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dapp/public/flags/ng.svg b/apps/dapp/public/flags/ng.svg new file mode 100644 index 00000000..81eb35f7 --- /dev/null +++ b/apps/dapp/public/flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/dapp/public/flags/tz.svg b/apps/dapp/public/flags/tz.svg new file mode 100644 index 00000000..a2cfbca4 --- /dev/null +++ b/apps/dapp/public/flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/dapp/public/flags/ug.svg b/apps/dapp/public/flags/ug.svg new file mode 100644 index 00000000..520eee5c --- /dev/null +++ b/apps/dapp/public/flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/superpowers/plans/2026-11-04-multi-step-on-every-chain.md b/docs/superpowers/plans/2026-11-04-multi-step-on-every-chain.md new file mode 100644 index 00000000..4ff8279f --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-multi-step-on-every-chain.md @@ -0,0 +1,326 @@ +# Multi-Step Transactions 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:** On Solana, Sui, NEAR, Avalanche and Monad, a person can do what Stellar already does in one conversation: see everything their wallet holds, keep their chat, and run a swap followed by a cash-out, deposit, stake or send. + +**Architecture:** Stellar's `use-sequence.ts` is built on XDR, Horizon and anchors, so it is copied in behaviour, not reused. A new `useChainSequence` hook sits beside the existing `useChainAction` and `useCashout`: the race and signing for step 1 are unchanged, then the hook measures what actually arrived and hands that amount to step 2, which the person starts themselves. Balances gain a `ChainModule.balances` read behind one route. The chat stays mounted across pages by moving `IntentChat` into the chain layout, and a read-only transcript survives reloads. + +**Tech Stack:** Next 15, React 19, TypeScript, viem, vitest (node, no DOM). pnpm monorepo, app in `apps/dapp`. + +**Spec:** No design document: the person chose to skip it and said "build". What they asked for, from testing the preview on Solana: + +- "the AI can only read solana wallet balance and not all the tokens" +- "first swap the sol to usdc then from usdc to fiat ... on all the chains not just stellar" +- "except the chat session ends or user starts new chat the chat should always persists" +- "Add each of the currency country logo" +- "multilayered txns of swap first then offramp then to other thing, for all the chain" + +## Facts this plan rests on + +Checked live on 2026-11-04. + +- 1Click dry quotes to USDC work for SOL, AVAX and NEAR. **Every Monad-origin quote answers HTTP 400 "Internal server error"**, including ones that passed earlier the same day. +- Solana listing: `solana-rpc.publicnode.com` refuses `getTokenAccountsByOwner` (403). `api.mainnet-beta.solana.com` answered for both token programs, from a home connection. Its limits from Vercel are untested, and `SOLANA_RPC_URL` is unset on Preview and Production. +- Sui: `graphql.mainnet.sui.io` answers `address.balances`. +- NEAR: `https://api.fastnear.com/v1/account/{id}/ft` lists every fungible token held, with raw balances. +- EVM has no "all tokens" RPC, so Avalanche and Monad read known tokens only. +- A Weave order sets `walletAddress` and `refundAddress` to the paying wallet on the deposit chain (`lib/weave/client.ts:312-335`). Paying from another chain would leave no valid refund address, so cross-chain cash-out is out of this plan. +- Weave charges about 1% over the order amount. `lib/weave` accepts up to 2% over. +- Weave on NEAR fails at the order step (`Incorrect depositMode`). Weave does not list Monad. +- Weave institutions: UGX has two mobile money providers and no banks; KES and TZS have a mix; NGN has banks only. + +## Global Constraints + +- Stellar's behaviour does not change. Any Stellar regression is a defect, including its chat path, `use-sequence.ts` and its naira form. +- Never edit `components/intents/naira-payout-card.tsx`, `components/intents/bank-picker.tsx` or `hooks/use-naira-payout.ts`. The cash-out form's layout stays as prod's. +- An answer may lack `pending` only if it proves nothing moved. A step 1 that is `pending` or unknown never advances to step 2. +- Nothing opens a wallet on its own. Step 2 starts only from a press by the person. +- Step 2's amount is always measured from the chain, never taken from a quote, and never guessed when a read fails. +- A follow-on that cannot run is refused before step 1 is signed. +- No agent signs or sends. Dry quotes only in tests. No Weave order is created by any test or agent. +- Agents pick route ids only. Nothing an LLM writes affects what is signed. +- Client bundles import no server-only module or secret. +- No module reads the Stellar network at import. Every task's focused run includes `import-purity` and `routes-load-with-two-networks`. +- User-facing copy is true on every path it can appear on, plain, with no em dashes. +- Every integrated app ships its real logo in the same change. Country flags are local files, not a CDN. +- Commits: lowercase subject, header at most 100, body lines at most 100, no co-author line, no attribution footer. Stage named files only; never `.claude/`, `apps/dapp/.next-verify/` or `.env.local`. +- No dev server: port 3001 is the person's. No `next build` on Windows. Do not switch branches. +- vitest is node with no DOM and no BigInt literals, so logic goes in pure helpers under `lib/`. +- Vitest flags: always `--maxWorkers=4 --minWorkers=1` or lower, never `--maxWorkers=1` alone. + +## Review Focus + +1. Spending a wallet's whole native balance ("offramp 0.01477 SOL" with 0.01477 SOL held): refused before signing, naming what can be spent. Test in Task 4. +2. Step 1 answers pending, or the wallet reports an error after sending: step 2 is never offered, and the person is told to check before doing anything. Test in Task 5. +3. Measuring what arrived fails or stays at zero: the person is told they hold the token and nothing was paid out, with no amount guessed. Test in Task 5. +4. A different wallet connects between step 1 and step 2: step 2 refuses with the existing wrong-wallet sentence. Test in Task 5. +5. A transcript restored after a reload holds nothing signable, and another wallet never sees it. Test in Task 3. + +--- + +### Task 1: Country logos and "mobile money" wording + +**Files:** + +- Create: `apps/dapp/public/flags/ng.svg`, `ke.svg`, `ug.svg`, `tz.svg`, `apps/dapp/lib/weave/currency-flag.ts`, `apps/dapp/lib/__tests__/currency-flag.test.ts` +- Modify: `apps/dapp/components/intents/cashout-currency-question.tsx`, `apps/dapp/components/intents/cashout-card.tsx` + +**Interfaces:** + +- Produces: `currencyFlag(code: string): { src: string; country: string } | undefined`, `payoutWord(types: readonly string[]): 'bank' | 'mobile money provider' | 'bank or mobile money'`. + +- [ ] **Step 1: Write failing tests.** Each of NGN, KES, UGX and TZS returns `/flags/{ng,ke,ug,tz}.svg` and its country name, and the file exists under `public/flags`. An unknown code returns undefined. `payoutWord(['mobile_money','mobile_money'])` is `'mobile money provider'`, `['bank']` is `'bank'`, `['bank','mobile_money']` is `'bank or mobile money'`. +- [ ] **Step 2: Run** `pnpm exec vitest run currency-flag`. Expected: FAIL. +- [ ] **Step 3: Implement.** Take the four flags from the MIT-licensed `flag-icons` package's 4x3 SVGs, and copy the files in rather than adding a dependency. Show the flag before the name on each currency button and in the cash-out form's currency display. Where `cashout-card.tsx` itself writes "bank" for a non-naira currency, use `payoutWord` on the loaded institutions. If a sentence is hard-coded inside `bank-picker.tsx`, leave it and report it. +- [ ] **Step 4: Run** the test file, `tsc`, then eslint. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): show each payout currency's country flag`. + +### Task 2: Every token the wallet holds, read fresh + +**Files:** + +- Create: `apps/dapp/lib/chains/balance-reply.ts`, `apps/dapp/app/api/chain/[chain]/balances/route.ts`, `apps/dapp/lib/chains/solana/balances.ts`, `apps/dapp/lib/chains/sui/balances.ts`, `apps/dapp/lib/chains/near/balances.ts`, `apps/dapp/lib/chains/evm/balances.ts`, `apps/dapp/lib/__tests__/chain-balances.test.ts` +- Modify: `apps/dapp/lib/chains/types.ts`, each chain's `module.ts`, `apps/dapp/components/intents/intent-chat.tsx:1650-1654` and its call site + +**Interfaces:** + +- Produces in `lib/chains/types.ts`: + +```ts +export interface TokenBalance { + symbol: string + id: string + amount: string + decimals: number + native: boolean + /** In this chain's token table. An unlisted token can't be traded here yet. */ + listed: boolean +} +export interface WalletBalances { + tokens: TokenBalance[] + /** False when only known tokens could be read. */ + complete: boolean +} +// ChainModule gains: +balances: (account: string) => Promise +``` + +- Produces: `GET /api/chain/[chain]/balances?account=` answering `WalletBalances`, or 503 `{ error }`. Rate limited with `enforceRateLimit(request, 'build')`. A malformed account answers 400. +- Produces: `chainBalanceReply(balances: WalletBalances | undefined, chainName: string): string`. + +- [ ] **Step 1: Write failing tests**, with fetch stubbed to real response shapes: + - Solana parses `getTokenAccountsByOwner` from both token programs plus `getBalance`, all with commitment `confirmed`. When listing is refused, it falls back to the token table with `complete: false`. + - Sui parses `address.balances`, and an unlisted coin's symbol is the coin type's last segment. + - NEAR parses the FastNEAR list, with a registry fallback and `complete: false` when it fails. + - EVM reads native plus each table token, always `complete: false`, and Avalanche never reads Monad's table. + - `chainBalanceReply`: native first, then listed, then unlisted with the shortened id (`AbCd…wXyZ`); zero balances dropped except native; over 30 tokens ends "and N more"; `complete: false` adds "Only tokens Intent knows on {chain} are shown."; `undefined` answers "I could not read your balance just now, so I won't guess. Try again in a moment." and no number. +- [ ] **Step 2: Run** `pnpm exec vitest run chain-balances`. Expected: FAIL. +- [ ] **Step 3: Implement.** + - Solana uses `SOLANA_RPC_URL`, else `https://api.mainnet-beta.solana.com`, on the server; never publicnode for listing. + - Sui takes decimals from GraphQL `coinMetadata`, and an unknown one leaves the token out rather than guessing. + - NEAR uses `https://api.fastnear.com/v1/account/{id}/ft`, with `ft_metadata` for at most 20 unlisted tokens. + - In the chat, the non-Stellar branch of `walletBalanceReply` becomes async and fetches this route at the moment of asking. Stellar's branch is untouched. +- [ ] **Step 4: Run** the test file, `import-purity`, `routes-load-with-two-networks`, `tsc`, then eslint. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): read every token a wallet holds on each chain`. + +### Task 3: The chat stays until New chat + +**Files:** + +- Create: `apps/dapp/components/intents/chat-host.tsx`, `apps/dapp/lib/chat/live-session.ts`, `apps/dapp/lib/__tests__/live-session.test.ts` +- Modify: `apps/dapp/app/[chain]/layout.tsx`, `apps/dapp/app/[chain]/intents/page.tsx`, `apps/dapp/app/[chain]/intents/new/page.tsx`, `apps/dapp/components/intents/intent-chat.tsx` + +**Interfaces:** + +- Produces in `lib/chat/live-session.ts`: + +```ts +export interface LiveTurn { + role: 'user' | 'agent' + text: string + explorerUrl?: string + at: number +} +export function liveSessionKey(chain: string, account: string): string +export function loadLiveSession( + storage: Pick | undefined, + chain: string, + account: string +): LiveTurn[] +export function appendLiveTurn( + storage: Pick | undefined, + chain: string, + account: string, + turn: LiveTurn +): void +export function clearLiveSession( + storage: Pick | undefined, + chain: string, + account: string +): void +``` + +- [ ] **Step 1: Write failing tests.** Two accounts or two chains never read each other's turns. The list caps at the newest 200. Broken storage or malformed JSON returns `[]` and never throws. Only `role`, `text`, `explorerUrl` and `at` are kept, with any other field dropped on load, so a saved entry cannot carry a signing payload, a route or a deposit address. `explorerUrl` must be `https:`, or it is dropped. +- [ ] **Step 2: Run** `pnpm exec vitest run live-session`. Expected: FAIL. +- [ ] **Step 3: Implement the mounted chat.** `ChatHost` is a client component in `app/[chain]/layout.tsx`, inside `ChainProvider`, keyed by chain. It renders the page header and `IntentChat` once a chain is open, and uses the `hidden` attribute when `usePathname()` is not `/{chain}/intents` or `/{chain}/intents/new`. The two page files stop rendering `IntentChat` and render nothing of their own beyond what they need. Keep whatever `intents/new` passes to the chat today. Because hooks stay mounted, a live race, card, latch and cash-out poll all survive navigation. +- [ ] **Step 4: Implement the transcript.** The chat appends each message it shows from the person and each agent reply, chain race outcome sentence, and settled hash. On mount with no live thread, it shows `loadLiveSession` for the connected account above the composer as plain read-only bubbles, with no buttons. `newChat()` calls `clearLiveSession`. Wallet disconnect hides it and doesn't delete it. A chain race settles to one sentence naming venue and result. Do not add chain races to `saveTurn`. +- [ ] **Step 5: Run** the test file, `import-purity`, `routes-load-with-two-networks`, `tsc`, then eslint. Expected: PASS. +- [ ] **Step 6: Commit** `feat(dapp): keep the chat until a new chat starts`. + +### Task 4: Reading a multi-step request, and refusing what can't finish + +**Files:** + +- Create: `apps/dapp/lib/chains/sequence.ts`, `apps/dapp/lib/chains/parse-sequence.ts`, `apps/dapp/lib/__tests__/chain-sequence.test.ts` +- Modify: `apps/dapp/lib/weave/parse-cashout.ts`, `apps/dapp/lib/weave/cashout-view.ts`, `apps/dapp/lib/chains/prepare.ts`, `apps/dapp/app/api/agents/compete/route.ts` + +**Interfaces:** + +- Produces in `lib/chains/sequence.ts`: + +```ts +export type FollowOn = + | { kind: 'cashout'; currency?: CashoutCurrencyCode } + | { kind: 'supply' | 'stake'; venue?: string } + | { kind: 'send'; recipient: string } +export interface ChainSequenceRequest { + swap: ChainActionRequest + followOn: FollowOn +} +export const SEQUENCE_CASHOUT_CHAINS: readonly ChainWithActions[] // solana, sui, avalanche +export function deliveredAmount( + before: string | undefined, + after: string | undefined, + decimals: number +): string | undefined +export function cashoutAmountFrom(delivered: string): string +export function followOnRefusal( + chain: ChainWithActions, + chainName: string, + followOn: FollowOn, + venues: readonly Pick[] +): string | undefined +export function strandedReply(symbol: string, amount: string | undefined): string +``` + +- Produces: `parseChainSequence(text: string): ChainSequenceRequest | { refusal: string } | null`. +- Produces in `lib/chains/prepare.ts`: `NATIVE_FEE_RESERVE: Record` with `solana '0.005'`, `sui '0.05'`, `near '0.05'`, `avalanche '0.01'`, `monad '0.05'`, and `assertNativeReserve(chainApi: ChainModule, request: ChainActionRequest, account: string): Promise`. +- `CashoutRead`'s `unsupportedToken` form gains `amount?: string`. `CashoutRoute` gains `{ kind: 'sequence'; token: string; amount: string; currency?: CashoutCurrencyCode }`. + +- [ ] **Step 1: Write failing tests:** + - `parseChainSequence` reads each of these into the right swap and follow-on: "swap 5 SOL to USDC then cash out", "swap 5 SOL to USDC and cash out to kes", "swap 5 SOL to USDC then deposit", "swap 5 SOL to USDC then stake", "swap 5 SOL to USDC then send to {address}". + - "swap 5 SOL to BONK then cash out" gives `{ refusal }` saying cash-outs take USDC. "swap 5 SOL to USDC" returns null, since it is a plain swap. A plain deposit returns null. + - `routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'solana')` gives `kind: 'sequence'`. The same on `stellar` still gives `pass`. With no amount it stays `token`. On `near` it stays `token`, and `unsupportedTokenReply` says there what is true on NEAR. Monad stays `pass`. + - `deliveredAmount('1.5','6.5',6)` is `'5'`. Undefined on either side, or `after <= before`, gives undefined. It uses BigInt on base units, so `0.1 + 0.2` errors can't occur. + - `cashoutAmountFrom('5')` is `'4.9'` and `cashoutAmountFrom('5.05')` is `'4.95'`: `floor(delivered / 1.02)` to 2 places on integers. + - `followOnRefusal`: cash-out on `near` names Weave's NEAR deposits being unavailable; on `monad` says cash-outs don't run there; `supply` with no venue offering it names the chain; `send` with a venue offering it answers undefined; every refusal ends "Nothing was signed or sent." + - `strandedReply('USDC','5')` includes "you are holding" and "nothing was paid out"; with undefined it includes no number. + - `assertNativeReserve`: wallet holds 0.01477 SOL and asks 0.01477 refuses "Keep about 0.005 SOL for network fees. You can spend up to 0.00977 SOL here. Nothing was signed or sent."; asks 0.009 passes; a token other than native passes with no read; a failed balance read passes, because it must not block a trade. +- [ ] **Step 2: Run** `pnpm exec vitest run chain-sequence`. Expected: FAIL. +- [ ] **Step 3: Implement.** Call `assertNativeReserve` in both `prepareChainAction` and `chainRaceFacts`, so a doomed amount is refused before the agents run. Chat wiring is Task 5. +- [ ] **Step 4: Run** the test file, the `parse-cashout` tests, `import-purity`, `routes-load-with-two-networks`, `tsc`, then eslint. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): read a swap and what comes after it`. + +### Task 5: Swap then cash out + +**Files:** + +- Create: `apps/dapp/hooks/use-chain-sequence.ts`, `apps/dapp/lib/chains/sequence-flow.ts`, `apps/dapp/components/intents/chain-sequence-steps.tsx`, `apps/dapp/lib/__tests__/chain-sequence-flow.test.ts` +- Modify: `apps/dapp/components/intents/intent-chat.tsx`, `apps/dapp/components/intents/cashout-currency-question.tsx` + +**Interfaces:** + +- Consumes: Task 4's types, Task 2's route, `useChainAction`, `useCashout.open`. +- Produces in `lib/chains/sequence-flow.ts`: + +```ts +export type SequenceStage = 'swap' | 'measuring' | 'ready' | 'next' | 'stranded' | 'done' +export function stageAfterSwap(phase: ChainActionPhase): SequenceStage | undefined +export function stageAfterMeasure( + delivered: string | undefined, + attempts: number, + maxAttempts: number +): SequenceStage | 'keep' +export const MEASURE_INTERVAL_MS = 3_000 +export const MEASURE_ATTEMPTS = 20 +``` + +- Produces in the hook: `useChainSequence(chainAction, cashout)` returning `{ sequence, stage, delivered, error, start(request, text), runNext(), clear() }`. + +- [ ] **Step 1: Write failing tests:** + - `stageAfterSwap('settled')` is `'measuring'`. + - `pending` and `failed` are `'stranded'`, and the signing, submitting, review and building phases are undefined. `pending` never reaches `'ready'` on any path. + - `stageAfterMeasure('5', 1, 20)` is `'ready'`. Undefined before the limit is `'keep'`, and at the limit is `'stranded'`. +- [ ] **Step 2: Run** `pnpm exec vitest run chain-sequence-flow`. Expected: FAIL. +- [ ] **Step 3: Implement the hook.** + - `start` reads the output token's balance from the balances route when step 1's card reaches `review`, and if that read fails, marks `before` as unknown. + - On `settled`, it polls until the amount rises. An unknown `before` goes straight to stranded rather than guessing. + - `runNext` for cash-out calls `cashout.open({ amount: cashoutAmountFrom(delivered), currency })`. It starts only from a press. + - It compares the connected account with the one step 1 was signed by, using `accountMismatch`, and refuses with that sentence if they differ. + - It adds no second latch: a new race is still what lifts `chainAction`'s latch. +- [ ] **Step 4: Wire the chat.** + - In `startCashout`, `kind: 'sequence'` first checks `followOnRefusal`, then asks the currency with the existing question if none is named, then runs the race for `{ kind: 'swap', amount, tokenIn: token, tokenOut: 'USDC' }`. + - `handleSubmit` tries `parseChainSequence` before `parseChainAction`. + - `ChainSequenceSteps` shows "1 Swap 0.5 SOL to USDC" and "2 Cash out to {currency}" before anything is signed, the step in progress, and a "Cash out {amount} USDC" button at `ready`. + - At `stranded`, it shows `strandedReply`. A pending step 1 shows the existing pending card, unchanged. +- [ ] **Step 5: Run** the test file, `agents-chain-race`, `import-purity`, `routes-load-with-two-networks`, `tsc`, then eslint. Expected: PASS. +- [ ] **Step 6: Commit** `feat(dapp): swap then cash out on solana, sui and avalanche`. + +### Task 6: Swap then deposit, stake or send, and plain send + +**Files:** + +- Create: `apps/dapp/lib/chains/send.ts`, `apps/dapp/lib/__tests__/chain-send.test.ts` +- Modify: `apps/dapp/lib/chains/types.ts`, `apps/dapp/lib/chains/parse-action.ts`, each chain's `module.ts`, `apps/dapp/hooks/use-chain-sequence.ts`, `apps/dapp/components/intents/chain-sequence-steps.tsx`, `apps/dapp/lib/agents/chain-routes.ts` + +**Interfaces:** + +- Consumes: the deposit builders in `lib/weave/deposit/*` and `lib/chains/evm/deposit.ts`, which already build and check one exact transfer. Reuse them; do not write a second transfer check. +- Produces: `ChainActionKind` gains `'send'`. `ChainActionRequest` gains `recipient?: string`. `createSendVenue(chain: ChainWithActions): ChainVenue` with id `send`, name `Wallet transfer`, kinds `['send']`, and no `quote`, so it never enters a race. + +- [ ] **Step 1: Write failing tests:** + - `parseChainAction('send 5 USDC to {address}')` gives kind `send` with the recipient unchanged in case. + - On each chain, `prepare` builds exactly one transfer of that token and amount to that recipient, and `assertSafe` refuses another recipient, another amount, another token and trailing data. + - A recipient that isn't valid on this chain is refused, including an EVM address on Solana and a Solana address on Sui. + - A token contract as recipient is refused. + - A NEAR recipient not registered with the token is refused with a plain sentence, since the transfer would fail. + - Native SOL, SUI or NEAR that the builder can't move is refused plainly. + - `assertWithinCap` applies. + - A send never enters `quoteChainRoutes`, and a typed send goes straight to a review card with no race. + - In the sequence: follow-on `supply`, `stake` or `send` prepares with `amount` equal to the measured delivery, and `tokenIn` equal to the swap's output. +- [ ] **Step 2: Run** `pnpm exec vitest run chain-send`. Expected: FAIL. +- [ ] **Step 3: Implement.** The review card shows the full recipient and the note "Transfers can't be undone. Check the address." Step 2's press calls `chainAction.release()` and then `prepare`, only from `stage === 'ready'`, so the latch is lifted once and only after step 1 is proven settled. Measured amounts over the token's decimals are floored. +- [ ] **Step 4: Run** the test file, `chain-sequence`, `import-purity`, `routes-load-with-two-networks`, `tsc`, then eslint. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): swap then deposit, stake or send on every chain`. + +### Task 7: The chat understands all of it in ordinary words + +**Files:** + +- Modify: `apps/dapp/lib/orchestrate/capabilities.ts`, `apps/dapp/lib/chains/capabilities.ts`, `apps/dapp/components/intents/intent-chat.tsx`, `apps/dapp/lib/chains/parse-sequence.ts` +- Test: `apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts`, `apps/dapp/lib/__tests__/chain-sequence.test.ts` + +**Interfaces:** + +- Produces: `sequenceFromReading(reading: UnderstoodIntent): ChainSequenceRequest | null` in `lib/chains/parse-sequence.ts`. + +- [ ] **Step 1: Write failing tests.** + - A reading with a swap and an off-ramp, lend or send `followOn` converts to the matching sequence. One that has no `followOn` returns null. + - `capabilitySheet` on Solana, Sui and Avalanche says swap then cash out runs. On NEAR it says swap then deposit and send run, and cash-out doesn't. On Monad it says swap and send run, and cash-out and deposit don't. + - Limit orders remain listed as not available off Stellar. + - `chainHelp` lists what works in the new forms. +- [ ] **Step 2: Run** those files. Expected: FAIL. +- [ ] **Step 3: Implement.** Off Stellar, where the orchestrator's reading has a `followOn`, the chat runs `sequenceFromReading` through the same path as Task 5, and never reaches the Stellar branches of `startIntent`. Drop `followOn` silently nowhere: a follow-on that can't convert is refused with `followOnRefusal`. +- [ ] **Step 4: Verify the whole change.** From `apps/dapp` 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, each named in the report with its cause. +- [ ] **Step 5: Commit** `feat(dapp): read multi-step requests in ordinary words on every chain`. + +## Not in this plan + +- Limit orders off Stellar (Kamino, DeepBook, Rhea, CoW) and new apps get their own plans. Research is in the session's scratchpad. +- Cash-out from NEAR or Monad: NEAR needs Weave to fix `Incorrect depositMode`. Monad needs Weave support, or a bridge step that needs gas on another chain. +- Trading a token that isn't in the chain's token table. Balances show it and mark it unlisted; a swap of it is refused as today. +- More than one follow-on after a swap. Stellar also runs one. +- Fixing 1Click's Monad errors, which are upstream. +- A history screen for chain races. +- A real signed transaction: the person does that with a funded wallet.