From 1e8e136f270daa83236ef84ceb0b3f3e7b59bd0c Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:07:50 +0100 Subject: [PATCH 01/27] docs: plan multi-step transactions on every chain --- .../2026-11-04-multi-step-on-every-chain.md | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-04-multi-step-on-every-chain.md 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. From faecfd94f9fe78cbfd11f7580b527199bdc3b13d Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:11:37 +0100 Subject: [PATCH 02/27] feat(dapp): show each payout currency's country flag --- apps/dapp/components/intents/cashout-card.tsx | 30 +++++++++++---- .../intents/cashout-currency-question.tsx | 38 +++++++++++++------ apps/dapp/lib/__tests__/currency-flag.test.ts | 35 +++++++++++++++++ apps/dapp/lib/weave/currency-flag.ts | 20 ++++++++++ apps/dapp/public/flags/ke.svg | 23 +++++++++++ apps/dapp/public/flags/ng.svg | 6 +++ apps/dapp/public/flags/tz.svg | 13 +++++++ apps/dapp/public/flags/ug.svg | 30 +++++++++++++++ 8 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 apps/dapp/lib/__tests__/currency-flag.test.ts create mode 100644 apps/dapp/lib/weave/currency-flag.ts create mode 100644 apps/dapp/public/flags/ke.svg create mode 100644 apps/dapp/public/flags/ng.svg create mode 100644 apps/dapp/public/flags/tz.svg create mode 100644 apps/dapp/public/flags/ug.svg 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..7d9101f1 100644 --- a/apps/dapp/components/intents/cashout-currency-question.tsx +++ b/apps/dapp/components/intents/cashout-currency-question.tsx @@ -3,6 +3,7 @@ import { cn } from '@intent/ui' 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 { @@ -40,18 +41,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/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/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/public/flags/ke.svg b/apps/dapp/public/flags/ke.svg new file mode 100644 index 00000000..3a67ca3c --- /dev/null +++ b/apps/dapp/public/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0820643bd3f06fba95815d42de5c818f4ab2ccf8 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:09:18 +0100 Subject: [PATCH 03/27] feat(dapp): read every token a wallet holds on each chain --- .../app/api/chain/[chain]/balances/route.ts | 33 ++ apps/dapp/components/intents/intent-chat.tsx | 25 +- .../dapp/lib/__tests__/chain-balances.test.ts | 376 ++++++++++++++++++ apps/dapp/lib/__tests__/evm-module.test.ts | 1 + .../lib/__tests__/solana-venues.live.test.ts | 1 + apps/dapp/lib/chains/balance-reply.ts | 35 ++ apps/dapp/lib/chains/evm/balances.ts | 42 ++ apps/dapp/lib/chains/evm/module.ts | 2 + apps/dapp/lib/chains/near/balances.ts | 116 ++++++ apps/dapp/lib/chains/near/module.ts | 2 + apps/dapp/lib/chains/near/rpc.ts | 11 + apps/dapp/lib/chains/solana/balances.ts | 120 ++++++ apps/dapp/lib/chains/solana/module.ts | 2 + apps/dapp/lib/chains/sui/balances.ts | 121 ++++++ apps/dapp/lib/chains/sui/module.ts | 2 + apps/dapp/lib/chains/types.ts | 18 + 16 files changed, 900 insertions(+), 7 deletions(-) create mode 100644 apps/dapp/app/api/chain/[chain]/balances/route.ts create mode 100644 apps/dapp/lib/__tests__/chain-balances.test.ts create mode 100644 apps/dapp/lib/chains/balance-reply.ts create mode 100644 apps/dapp/lib/chains/evm/balances.ts create mode 100644 apps/dapp/lib/chains/near/balances.ts create mode 100644 apps/dapp/lib/chains/solana/balances.ts create mode 100644 apps/dapp/lib/chains/sui/balances.ts 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..75ac1db9 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/balances/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' + +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' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** 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, 'build') + 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) + 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/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index c6c2b4af..b39ebdc3 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -62,10 +62,11 @@ 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 { 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 type { ChainActionRequest, WalletBalances } from '../../lib/chains/types' import { useChainAction } from '../../hooks/use-chain-action' import { ChainActionConfirm } from './chain-action-confirm' import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout' @@ -323,7 +324,7 @@ export function IntentChat(): JSX.Element { strategy.slippageBps !== undefined ? { slippageBps: strategy.slippageBps } : {} const { slug, descriptor } = useChain() - const { address, isConnected, balance: nativeBalance, balanceSymbol } = useWallet() + const { address, isConnected } = useWallet() // Checked once the wallet that owns it is known — historyOwner, which // pendingCashOut reads, is set from this same address elsewhere — so a @@ -1646,11 +1647,21 @@ 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' } + ) + if (response.ok) held = (await response.json()) as WalletBalances + } catch { + held = undefined + } + return chainBalanceReply(held, descriptor.name) } async function clarify( @@ -1697,7 +1708,7 @@ export function IntentChat(): JSX.Element { answer.outcome === 'help' ? (answer.reply ?? helpMessage(slug)) : answer.outcome === 'balance' - ? walletBalanceReply() + ? await walletBalanceReply() : answer.outcome === 'out_of_scope' ? outOfScopeMessage(slug, answer.reply) : answer.outcome === 'unclear' 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..ef25e109 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-balances.test.ts @@ -0,0 +1,376 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { chainBalanceReply } 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 + 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) + }) + + it('falls back to the token table when listing is refused', async () => { + stubFetch((_url, body) => { + if (method(body) === 'getBalance') return { result: { value: 5_000_000_000 } } + if (method(body) === 'getTokenAccountsByOwner') { + return new Response('forbidden', { status: 403 }) + } + return { result: { value: tokenAccount(USDC_MINT, '250000', 6).account } } + }) + 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') + }) + + 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('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))) }, + }) + + it('reads the FastNEAR list and native NEAR, with metadata for unlisted tokens', async () => { + stubFetch((url, body) => { + if (url.startsWith('https://api.fastnear.com/v1/account/alice.near/ft')) { + return { + account_id: 'alice.near', + tokens: [ + { contract_id: USDC_NEAR, last_update_block_height: 1, balance: '3000000' }, + { contract_id: 'token.example.near', last_update_block_height: 1, balance: '42000' }, + { contract_id: 'empty.near', last_update_block_height: 1, balance: '0' }, + ], + } + } + const b = body as { params: { request_type: string; account_id: string } } + if (b.params.request_type === 'view_account') return viewAccount + return callResult({ symbol: 'EXA', decimals: 3 }) + }) + 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, + }) + ) + }) + + it('falls back to the registry with complete false when FastNEAR fails', async () => { + stubFetch((url, body) => { + if (url.includes('fastnear.com/v1')) return new Response('x', { status: 500 }) + const b = body as { params: { request_type: string; account_id: string } } + if (b.params.request_type === 'view_account') return viewAccount + return callResult('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('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.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('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 incomplete', () => { + const reply = chainBalanceReply( + wallet([t({ symbol: 'AVAX', native: true })], false), + 'Avalanche' + ) + expect(reply).toContain('Only tokens Intent knows on Avalanche are shown.') + }) + + 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__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 7c4b0048..aa168593 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -503,6 +503,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 => ({ 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/chains/balance-reply.ts b/apps/dapp/lib/chains/balance-reply.ts new file mode 100644 index 00000000..e557825d --- /dev/null +++ b/apps/dapp/lib/chains/balance-reply.ts @@ -0,0 +1,35 @@ +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 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(`Only tokens Intent knows on ${chainName} are shown.`) + return parts.join(String.fromCharCode(10)) +} diff --git a/apps/dapp/lib/chains/evm/balances.ts b/apps/dapp/lib/chains/evm/balances.ts new file mode 100644 index 00000000..998f6965 --- /dev/null +++ b/apps/dapp/lib/chains/evm/balances.ts @@ -0,0 +1,42 @@ +import type { Address } from 'viem' + +import { fromBaseUnits } from '../solana/tokens' +import { 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}$/ + +/** + * 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) +): 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 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, + } + }) + ) + return { tokens, complete: false } +} diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts index 8f45510c..02b7aaf0 100644 --- a/apps/dapp/lib/chains/evm/module.ts +++ b/apps/dapp/lib/chains/evm/module.ts @@ -1,6 +1,7 @@ import { oneClickPrice } from '../near/oneclick' import { canIssueOneClick, oneClickAssetId, oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule, ChainToken, ChainVenue } from '../types' +import { readEvmBalances } from './balances' import { EVM_CHAINS, type EvmChain } from './tokens' export function buildEvmVenues( @@ -37,6 +38,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) => readEvmBalances(chain, account), ...(tokenHints !== undefined ? { tokenHints } : {}), } } diff --git a/apps/dapp/lib/chains/near/balances.ts b/apps/dapp/lib/chains/near/balances.ts new file mode 100644 index 00000000..1ccef90a --- /dev/null +++ b/apps/dapp/lib/chains/near/balances.ts @@ -0,0 +1,116 @@ +import { fromBaseUnits } from '../solana/tokens' +import { 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 TIMEOUT_MS = 10_000 +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): Promise<{ id: string; base: string }[]> { + const response = await fetch(`${FASTNEAR}/${encodeURIComponent(account)}/ft`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + }) + 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 } + }) +} + +function symbolFor(id: string, symbol: string): string { + return id === WRAP_NEAR ? 'wNEAR' : symbol +} + +function listedEntry(id: string, base: string): TokenBalance | undefined { + const token = nearTokens.find((t) => t.id === id) + if (token === undefined) return undefined + return { + symbol: symbolFor(id, token.symbol), + id, + amount: fromBaseUnits(base, token.decimals), + decimals: token.decimals, + native: false, + listed: true, + } +} + +async function unlistedEntry(id: string, base: string): Promise { + try { + const meta = await viewCall<{ symbol?: unknown; decimals?: unknown }>(id, 'ft_metadata') + 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 { + return undefined + } +} + +async function fromRegistry(account: string): Promise { + const out: TokenBalance[] = [] + for (const token of nearTokens) { + const base = await viewCall(token.id, 'ft_balance_of', { account_id: account }) + if (typeof base !== 'string') throw new Error('ft balance unreadable') + const entry = listedEntry(token.id, base) + if (entry !== undefined) out.push(entry) + } + return out +} + +export async function readNearBalances(account: string): Promise { + if (!NEAR_ACCOUNT.test(account)) { + throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.') + } + const native: TokenBalance = { + symbol: 'NEAR', + id: NATIVE_ID, + amount: fromBaseUnits(await accountBalance(account), NEAR_DECIMALS), + decimals: NEAR_DECIMALS, + native: true, + listed: true, + } + let held: { id: string; base: string }[] + try { + held = await fastNearList(account) + } catch { + return { tokens: [native, ...(await fromRegistry(account))], complete: false } + } + let complete = true + let metadataReads = 0 + const tokens: TokenBalance[] = [native] + for (const { id, base } of held) { + const listed = listedEntry(id, base) + if (listed !== undefined) { + tokens.push(listed) + continue + } + if (/^0+$/.test(base)) continue + if (metadataReads >= MAX_METADATA_READS) { + complete = false + continue + } + metadataReads += 1 + const entry = await unlistedEntry(id, base) + if (entry === undefined) complete = false + else tokens.push(entry) + } + return { tokens, complete } +} diff --git a/apps/dapp/lib/chains/near/module.ts b/apps/dapp/lib/chains/near/module.ts index 0ad0bc51..484590fa 100644 --- a/apps/dapp/lib/chains/near/module.ts +++ b/apps/dapp/lib/chains/near/module.ts @@ -1,4 +1,5 @@ import type { ChainModule } 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' @@ -21,4 +22,5 @@ export const nearModule: ChainModule = { }, 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..a3c08320 100644 --- a/apps/dapp/lib/chains/near/rpc.ts +++ b/apps/dapp/lib/chains/near/rpc.ts @@ -157,3 +157,14 @@ 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): Promise { + const result = (await rpcCall( + 'query', + { request_type: 'view_account', finality: 'final', account_id: account }, + VIEW_TIMEOUT_MS + )) as { amount?: unknown } + if (typeof result.amount !== 'string') throw new NearRpcError('account balance unreadable') + return result.amount +} diff --git a/apps/dapp/lib/chains/solana/balances.ts b/apps/dapp/lib/chains/solana/balances.ts new file mode 100644 index 00000000..bf4f3bf2 --- /dev/null +++ b/apps/dapp/lib/chains/solana/balances.ts @@ -0,0 +1,120 @@ +import { ChainActionRefused, type TokenBalance, type WalletBalances } from '../types' +import { associatedTokenAccount } from './assert-safe' +import { fromBaseUnits, SOLANA_TOKENS } from './tokens' + +const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com' +const TOKEN_PROGRAMS = [ + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +] +const TIMEOUT_MS = 10_000 +const ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ +const SOL = SOLANA_TOKENS[0]! + +class RpcRefused extends Error {} + +async function rpc(method: string, params: unknown[]): 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: AbortSignal.timeout(TIMEOUT_MS), + }) + 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 +} + +function parsedInfo(data: unknown): { mint: string; amount: string; decimals: number } | 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 listAll(account: string): Promise { + const held = new Map() + for (const programId of TOKEN_PROGRAMS) { + const result = (await rpc('getTokenAccountsByOwner', [ + account, + { programId }, + { encoding: 'jsonParsed', commitment: 'confirmed' }, + ])) as { value?: { account?: { data?: unknown } }[] } + if (!Array.isArray(result.value)) throw new RpcRefused('token accounts unreadable') + for (const item of result.value) { + const info = parsedInfo(item.account?.data) + if (info === undefined) throw new RpcRefused('token account unreadable') + held.set(info.mint, { + base: (held.get(info.mint)?.base ?? BigInt(0)) + BigInt(info.amount), + decimals: info.decimals, + }) + } + } + return [...held].map(([mint, { base, decimals }]) => entry(mint, base, decimals)) +} + +async function readTable(account: string): Promise { + const out: TokenBalance[] = [] + for (const token of SOLANA_TOKENS) { + if (token === SOL) continue + const result = (await rpc('getAccountInfo', [ + associatedTokenAccount(account, token.id), + { encoding: 'jsonParsed', commitment: 'confirmed' }, + ])) as { value: { data?: unknown } | null } + const info = result.value === null ? undefined : parsedInfo(result.value.data) + if (result.value !== null && info === undefined) throw new RpcRefused('account unreadable') + out.push(entry(token.id, BigInt(info?.amount ?? '0'), token.decimals)) + } + return out +} + +/** 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): Promise { + if (!ADDRESS.test(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' }])) 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))], complete: true } + } catch (e) { + if (!(e instanceof RpcRefused)) throw e + return { tokens: [native, ...(await readTable(account))], complete: false } + } +} diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts index e8fff1ac..a9526489 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -1,5 +1,6 @@ import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule, ChainVenue } from '../types' +import { readSolanaBalances } from './balances' import { createJupiterLendVenue } from './jupiter-lend' import { createJupiterSwapVenue } from './jupiter-swap' import { createKaminoVenue } from './kamino' @@ -33,4 +34,5 @@ export const solanaModule: ChainModule = { defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend' }, 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..7045fde5 --- /dev/null +++ b/apps/dapp/lib/chains/sui/balances.ts @@ -0,0 +1,121 @@ +import { fromBaseUnits } from '../solana/tokens' +import { ChainActionRefused, type TokenBalance, type WalletBalances } from '../types' +import { SUI_TOKENS } from './tokens' + +const GRAPHQL_URL = 'https://graphql.mainnet.sui.io/graphql' +const TIMEOUT_MS = 10_000 +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): Promise { + const response = await fetch(GRAPHQL_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }) + 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') +} + +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): 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 })) 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[]): 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, {})) 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): 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) + let complete = !more + const unlisted = held.filter((h) => !SUI_TOKENS.some((t) => t.id === h.type)).map((h) => h.type) + const decimals = await decimalsOf(unlisted) + 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 + } + 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/module.ts b/apps/dapp/lib/chains/sui/module.ts index a1d692f1..49beb955 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,5 +1,6 @@ import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule, ChainVenue } from '../types' +import { readSuiBalances } from './balances' import { cetusVenue } from './cetus' import { naviVenue } from './navi' import { priceFromLlama } from './price' @@ -25,4 +26,5 @@ export const suiModule: ChainModule = { defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui' }, priceUsd: (token) => priceFromLlama(token), submit: (input) => submitSui(input), + balances: readSuiBalances, } diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 081f448a..8d8d11ce 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -138,6 +138,22 @@ export interface ChainVenue { submit?: ((input: SubmitInput) => Promise) | undefined } +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 +} + export interface ChainModule { chain: ChainWithActions tokens: readonly ChainToken[] @@ -147,6 +163,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) => Promise /** A sentence for a symbol this chain doesn't list but a person may well type, by upper-case symbol. */ tokenHints?: Record | undefined } From 1545f841fcdfe8e517993c41aa6388440440701c Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:18:21 +0100 Subject: [PATCH 04/27] fix(dapp): read held tokens from the chain, bound the read and drop stale balance replies --- .../app/api/chain/[chain]/balances/route.ts | 5 +- apps/dapp/components/intents/intent-chat.tsx | 29 ++- .../dapp/lib/__tests__/chain-balances.test.ts | 219 +++++++++++++++--- apps/dapp/lib/chains/balance-reply.ts | 25 +- apps/dapp/lib/chains/evm/balances.ts | 55 +++-- apps/dapp/lib/chains/evm/module.ts | 2 +- apps/dapp/lib/chains/near/balances.ts | 119 +++++----- apps/dapp/lib/chains/near/rpc.ts | 23 +- apps/dapp/lib/chains/solana/balances.ts | 123 ++++++---- apps/dapp/lib/chains/sui/balances.ts | 45 +++- apps/dapp/lib/chains/types.ts | 16 +- 11 files changed, 487 insertions(+), 174 deletions(-) diff --git a/apps/dapp/app/api/chain/[chain]/balances/route.ts b/apps/dapp/app/api/chain/[chain]/balances/route.ts index 75ac1db9..3f1c100f 100644 --- a/apps/dapp/app/api/chain/[chain]/balances/route.ts +++ b/apps/dapp/app/api/chain/[chain]/balances/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from 'next/server' import { chainModule } from '../../../../../lib/chains/registry' -import { ChainActionRefused } from '../../../../../lib/chains/types' +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( @@ -21,7 +22,7 @@ export async function GET( const account = new URL(request.url).searchParams.get('account') ?? '' try { - const balances = await chainApi.balances(account) + 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) { diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index b39ebdc3..688009c0 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -62,7 +62,7 @@ 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 { chainBalanceReply } from '../../lib/chains/balance-reply' +import { balanceReplyIsCurrent, 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' @@ -263,6 +263,8 @@ 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 }) function openInbox(): void { const items = inbox.items @@ -325,6 +327,8 @@ export function IntentChat(): JSX.Element { const { slug, descriptor } = useChain() const { address, isConnected } = useWallet() + latest.current.chain = slug + latest.current.account = address // Checked once the wallet that owns it is known — historyOwner, which // pendingCashOut reads, is set from this same address elsewhere — so a @@ -1533,6 +1537,7 @@ 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) @@ -1655,9 +1660,12 @@ export function IntentChat(): JSX.Element { try { const response = await fetch( `/api/chain/${slug}/balances?account=${encodeURIComponent(address)}`, - { cache: 'no-store' } + { cache: 'no-store', signal: AbortSignal.timeout(20_000) } ) - if (response.ok) held = (await response.json()) as WalletBalances + if (response.ok) { + const body = (await response.json()) as Partial + if (Array.isArray(body.tokens)) held = body as WalletBalances + } } catch { held = undefined } @@ -1680,6 +1688,8 @@ export function IntentChat(): JSX.Element { setMessage(text) setTurnId(crypto.randomUUID()) } + latest.current.turn += 1 + const asked = { ...latest.current } setClarifying({ turns }) setClarifyBusy(true) @@ -1688,6 +1698,17 @@ 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) + // 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 + if (balanceText !== undefined && !balanceReplyIsCurrent(asked, latest.current)) { + // A newer message owns the screen; with none, only the wait is cleared. + if (latest.current.turn === asked.turn) { + setClarifyBusy(false) + setClarifying(null) + } + return + } setClarifyBusy(false) if (answer.outcome === 'ask') { @@ -1708,7 +1729,7 @@ export function IntentChat(): JSX.Element { answer.outcome === 'help' ? (answer.reply ?? helpMessage(slug)) : answer.outcome === 'balance' - ? await walletBalanceReply() + ? (balanceText as string) : answer.outcome === 'out_of_scope' ? outOfScopeMessage(slug, answer.reply) : answer.outcome === 'unclear' diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts index ef25e109..075b2830 100644 --- a/apps/dapp/lib/__tests__/chain-balances.test.ts +++ b/apps/dapp/lib/__tests__/chain-balances.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { chainBalanceReply } from '../chains/balance-reply' +import { balanceReplyIsCurrent, chainBalanceReply } 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' @@ -26,6 +26,7 @@ function stubFetch(handler: Handler): void { '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 @@ -94,18 +95,61 @@ describe('Solana balances', () => { expect(calls.every((c) => c.url === 'https://rpc.example.test')).toBe(true) }) - it('falls back to the token table when listing is refused', async () => { + 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 } } - if (method(body) === 'getTokenAccountsByOwner') { - return new Response('forbidden', { status: 403 }) + 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)] } } } - return { result: { value: tokenAccount(USDC_MINT, '250000', 6).account } } + 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.native)?.amount).toBe('5') - expect(result.tokens.find((t) => t.symbol === 'USDC')?.amount).toBe('0.25') + 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 () => { @@ -181,6 +225,28 @@ describe('Sui balances', () => { 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() @@ -197,23 +263,40 @@ describe('NEAR balances', () => { 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 the FastNEAR list and native NEAR, with metadata for unlisted tokens', async () => { - stubFetch((url, body) => { - if (url.startsWith('https://api.fastnear.com/v1/account/alice.near/ft')) { - return { - account_id: 'alice.near', - tokens: [ - { contract_id: USDC_NEAR, last_update_block_height: 1, balance: '3000000' }, + 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' }, - ], - } - } - const b = body as { params: { request_type: string; account_id: string } } - if (b.params.request_type === 'view_account') return viewAccount - return callResult({ symbol: 'EXA', decimals: 3 }) - }) + ]), + { [USDC_NEAR]: '3000000' } + ) + ) const result = await readNearBalances('alice.near') expect(result.complete).toBe(true) expect(result.tokens).toContainEqual( @@ -230,21 +313,59 @@ describe('NEAR balances', () => { listed: false, }) ) + expect(result.tokens.find((t) => t.id === 'empty.near')).toBeUndefined() }) - it('falls back to the registry with complete false when FastNEAR fails', async () => { - stubFetch((url, body) => { - if (url.includes('fastnear.com/v1')) return new Response('x', { status: 500 }) - const b = body as { params: { request_type: string; account_id: string } } - if (b.params.request_type === 'view_account') return viewAccount - return callResult('1000000') - }) + 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) }) @@ -273,6 +394,7 @@ describe('EVM balances', () => { 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 }) ) @@ -306,6 +428,37 @@ describe('EVM balances', () => { }) }) +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('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, @@ -358,14 +511,20 @@ describe('chainBalanceReply', () => { expect(reply).toContain('and 5 more') }) - it('says only known tokens are shown when incomplete', () => { + it('says only known tokens are shown when an EVM chain is incomplete', () => { const reply = chainBalanceReply( - wallet([t({ symbol: 'AVAX', native: true })], false), + { ...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( diff --git a/apps/dapp/lib/chains/balance-reply.ts b/apps/dapp/lib/chains/balance-reply.ts index e557825d..b6805c7a 100644 --- a/apps/dapp/lib/chains/balance-reply.ts +++ b/apps/dapp/lib/chains/balance-reply.ts @@ -18,6 +18,23 @@ 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 !== undefined && + asked.account === now.account && + asked.chain === now.chain && + asked.turn === now.turn + ) +} + 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)) @@ -30,6 +47,12 @@ export function chainBalanceReply(balances: WalletBalances | undefined, chainNam 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(`Only tokens Intent knows on ${chainName} are shown.`) + 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/evm/balances.ts b/apps/dapp/lib/chains/evm/balances.ts index 998f6965..a5800d1b 100644 --- a/apps/dapp/lib/chains/evm/balances.ts +++ b/apps/dapp/lib/chains/evm/balances.ts @@ -1,12 +1,27 @@ import type { Address } from 'viem' import { fromBaseUnits } from '../solana/tokens' -import { ChainActionRefused, withArticle, type TokenBalance, type WalletBalances } from '../types' +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. @@ -14,7 +29,8 @@ const ADDRESS = /^0x[0-9a-fA-F]{40}$/ export async function readEvmBalances( chain: EvmChain, account: string, - client: EvmClient = evmClient(chain) + client: EvmClient = evmClient(chain), + signal: AbortSignal = AbortSignal.timeout(BALANCE_DEADLINE_MS) ): Promise { if (!ADDRESS.test(account)) { throw new ChainActionRefused( @@ -22,21 +38,24 @@ export async function readEvmBalances( ) } const owner = account as Address - const tokens = await 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, - } - }) + 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 } + return { tokens, complete: false, knownOnly: true } } diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts index 02b7aaf0..002fd162 100644 --- a/apps/dapp/lib/chains/evm/module.ts +++ b/apps/dapp/lib/chains/evm/module.ts @@ -38,7 +38,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) => readEvmBalances(chain, account), + balances: (account, signal) => readEvmBalances(chain, account, undefined, signal), ...(tokenHints !== undefined ? { tokenHints } : {}), } } diff --git a/apps/dapp/lib/chains/near/balances.ts b/apps/dapp/lib/chains/near/balances.ts index 1ccef90a..b64bd66b 100644 --- a/apps/dapp/lib/chains/near/balances.ts +++ b/apps/dapp/lib/chains/near/balances.ts @@ -1,11 +1,15 @@ import { fromBaseUnits } from '../solana/tokens' -import { ChainActionRefused, type TokenBalance, type WalletBalances } from '../types' +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 TIMEOUT_MS = 10_000 const MAX_METADATA_READS = 20 const NEAR_DECIMALS = 24 const NATIVE_ID = 'near' @@ -15,10 +19,11 @@ interface FtHolding { balance?: string } -async function fastNearList(account: string): Promise<{ id: string; base: string }[]> { - const response = await fetch(`${FASTNEAR}/${encodeURIComponent(account)}/ft`, { - signal: AbortSignal.timeout(TIMEOUT_MS), - }) +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') @@ -30,16 +35,17 @@ async function fastNearList(account: string): Promise<{ id: string; base: string }) } -function symbolFor(id: string, symbol: string): string { - return id === WRAP_NEAR ? 'wNEAR' : symbol -} - -function listedEntry(id: string, base: string): TokenBalance | undefined { - const token = nearTokens.find((t) => t.id === id) - if (token === undefined) return undefined +/** 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: symbolFor(id, token.symbol), - id, + symbol: token.id === WRAP_NEAR ? 'wNEAR' : token.symbol, + id: token.id, amount: fromBaseUnits(base, token.decimals), decimals: token.decimals, native: false, @@ -47,9 +53,18 @@ function listedEntry(id: string, base: string): TokenBalance | undefined { } } -async function unlistedEntry(id: string, base: string): Promise { +async function unlistedEntry( + id: string, + base: string, + signal: AbortSignal +): Promise { try { - const meta = await viewCall<{ symbol?: unknown; decimals?: unknown }>(id, 'ft_metadata') + 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, @@ -59,58 +74,50 @@ async function unlistedEntry(id: string, base: string): Promise { - const out: TokenBalance[] = [] - for (const token of nearTokens) { - const base = await viewCall(token.id, 'ft_balance_of', { account_id: account }) - if (typeof base !== 'string') throw new Error('ft balance unreadable') - const entry = listedEntry(token.id, base) - if (entry !== undefined) out.push(entry) - } - return out -} - -export async function readNearBalances(account: string): Promise { +/** + * 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(await accountBalance(account), NEAR_DECIMALS), + amount: fromBaseUnits(nativeBase, NEAR_DECIMALS), decimals: NEAR_DECIMALS, native: true, listed: true, } - let held: { id: string; base: string }[] - try { - held = await fastNearList(account) - } catch { - return { tokens: [native, ...(await fromRegistry(account))], complete: false } - } - let complete = true - let metadataReads = 0 - const tokens: TokenBalance[] = [native] - for (const { id, base } of held) { - const listed = listedEntry(id, base) - if (listed !== undefined) { - tokens.push(listed) - continue - } - if (/^0+$/.test(base)) continue - if (metadataReads >= MAX_METADATA_READS) { - complete = false - continue - } - metadataReads += 1 - const entry = await unlistedEntry(id, base) - if (entry === undefined) complete = false - else tokens.push(entry) + 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, } - return { tokens, complete } } diff --git a/apps/dapp/lib/chains/near/rpc.ts b/apps/dapp/lib/chains/near/rpc.ts index a3c08320..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') @@ -159,11 +169,12 @@ export async function waitForTransaction( } /** Yocto of NEAR an account holds in total, storage included, read at final. */ -export async function accountBalance(account: string): Promise { +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 + 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/solana/balances.ts b/apps/dapp/lib/chains/solana/balances.ts index bf4f3bf2..c85fce48 100644 --- a/apps/dapp/lib/chains/solana/balances.ts +++ b/apps/dapp/lib/chains/solana/balances.ts @@ -1,5 +1,11 @@ -import { ChainActionRefused, type TokenBalance, type WalletBalances } from '../types' -import { associatedTokenAccount } from './assert-safe' +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' @@ -7,19 +13,18 @@ const TOKEN_PROGRAMS = [ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', ] -const TIMEOUT_MS = 10_000 -const ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ 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[]): Promise { +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: AbortSignal.timeout(TIMEOUT_MS), + signal, }) if (!response.ok) throw new RpcRefused(`${method} answered ${response.status}`) const body = (await response.json()) as { result?: unknown; error?: unknown } @@ -29,7 +34,13 @@ async function rpc(method: string, params: unknown[]): Promise { return body.result } -function parsedInfo(data: unknown): { mint: string; amount: string; decimals: number } | undefined { +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 @@ -56,50 +67,77 @@ function entry(mint: string, base: bigint, decimals: number): TokenBalance { } } -async function listAll(account: string): Promise { - const held = new Map() - for (const programId of TOKEN_PROGRAMS) { - const result = (await rpc('getTokenAccountsByOwner', [ - account, - { programId }, - { encoding: 'jsonParsed', commitment: 'confirmed' }, - ])) as { value?: { account?: { data?: unknown } }[] } - if (!Array.isArray(result.value)) throw new RpcRefused('token accounts unreadable') - for (const item of result.value) { - const info = parsedInfo(item.account?.data) - if (info === undefined) throw new RpcRefused('token account unreadable') - held.set(info.mint, { - base: (held.get(info.mint)?.base ?? BigInt(0)) + BigInt(info.amount), - decimals: info.decimals, - }) - } +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 [...held].map(([mint, { base, decimals }]) => entry(mint, base, 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()) } -async function readTable(account: string): Promise { - const out: TokenBalance[] = [] - for (const token of SOLANA_TOKENS) { - if (token === SOL) continue - const result = (await rpc('getAccountInfo', [ - associatedTokenAccount(account, token.id), - { encoding: 'jsonParsed', commitment: 'confirmed' }, - ])) as { value: { data?: unknown } | null } - const info = result.value === null ? undefined : parsedInfo(result.value.data) - if (result.value !== null && info === undefined) throw new RpcRefused('account unreadable') - out.push(entry(token.id, BigInt(info?.amount ?? '0'), token.decimals)) +/** + * 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), } - return out } /** 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): Promise { - if (!ADDRESS.test(account)) { +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' }])) as { + const lamports = (await rpc('getBalance', [account, { commitment: 'confirmed' }], signal)) as { value?: unknown } if (typeof lamports.value !== 'number') throw new RpcRefused('balance unreadable') @@ -112,9 +150,10 @@ export async function readSolanaBalances(account: string): Promise): Promise { +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: AbortSignal.timeout(TIMEOUT_MS), + signal, }) if (!response.ok) throw new Error(`Sui GraphQL answered ${response.status}`) const body = (await response.json()) as { data?: unknown; errors?: unknown } @@ -31,6 +39,10 @@ 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 @@ -45,11 +57,14 @@ interface BalancePage { } | null } -async function readAll(account: string): Promise<{ held: Held[]; more: boolean }> { +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 })) as BalancePage + 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) { @@ -66,13 +81,13 @@ async function readAll(account: string): Promise<{ held: Held[]; more: boolean } } /** Decimals from the chain's own metadata. A coin with none is left out, not guessed. */ -async function decimalsOf(types: string[]): Promise> { +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, {})) as Record + const data = (await graphql(query, {}, signal)) as Record const out = new Map() wanted.forEach((type, i) => { const decimals = data[`a${i}`]?.decimals @@ -81,14 +96,19 @@ async function decimalsOf(types: string[]): Promise> { return out } -export async function readSuiBalances(account: string): Promise { +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) + const { held, more } = await readAll(account, signal) let complete = !more - const unlisted = held.filter((h) => !SUI_TOKENS.some((t) => t.id === h.type)).map((h) => h.type) - const decimals = await decimalsOf(unlisted) + 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) @@ -103,6 +123,7 @@ export async function readSuiBalances(account: string): Promise }) continue } + if (isZero(base)) continue const places = decimals.get(type) if (places === undefined) { complete = false diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 8d8d11ce..75263cec 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -138,6 +138,11 @@ export interface ChainVenue { submit?: ((input: SubmitInput) => Promise) | undefined } +/** + * 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 @@ -150,10 +155,17 @@ export interface TokenBalance { export interface WalletBalances { tokens: TokenBalance[] - /** False when only known tokens could be read. */ + /** 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[] @@ -164,7 +176,7 @@ export interface ChainModule { /** 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) => Promise + 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 } From 4f506144af69c909b401b5898eafbeae3812b52c Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:22:21 +0100 Subject: [PATCH 05/27] fix(dapp): never leave the chat busy after a dropped balance reply, and clear it on new chat --- apps/dapp/components/intents/intent-chat.tsx | 24 +++++++++----- .../dapp/lib/__tests__/chain-balances.test.ts | 31 ++++++++++++++++++- apps/dapp/lib/chains/balance-reply.ts | 16 ++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 688009c0..4de93d4a 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -62,7 +62,7 @@ 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 { balanceReplyIsCurrent, chainBalanceReply } from '../../lib/chains/balance-reply' +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' @@ -265,6 +265,8 @@ export function IntentChat(): JSX.Element { 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 @@ -1690,6 +1692,7 @@ export function IntentChat(): JSX.Element { } latest.current.turn += 1 const asked = { ...latest.current } + clarifyRuns.current += 1 setClarifying({ turns }) setClarifyBusy(true) @@ -1701,15 +1704,18 @@ export function IntentChat(): JSX.Element { // 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 - if (balanceText !== undefined && !balanceReplyIsCurrent(asked, latest.current)) { - // A newer message owns the screen; with none, only the wait is cleared. - if (latest.current.turn === asked.turn) { - setClarifyBusy(false) - setClarifying(null) - } + clarifyRuns.current -= 1 + if (clarifyRuns.current === 0) setClarifyBusy(false) + 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 } - setClarifyBusy(false) if (answer.outcome === 'ask') { setClarifying({ turns: [...turns, { role: 'assistant', content: answer.question }] }) @@ -1823,6 +1829,7 @@ export function IntentChat(): JSX.Element { /** A clean slate: the open intent, the turns before it and any notes. */ function newChat(): void { + latest.current.turn += 1 handleReset() setThread([]) setNotes([]) @@ -1836,6 +1843,7 @@ 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 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) diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts index 075b2830..da97aaf9 100644 --- a/apps/dapp/lib/__tests__/chain-balances.test.ts +++ b/apps/dapp/lib/__tests__/chain-balances.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { balanceReplyIsCurrent, chainBalanceReply } from '../chains/balance-reply' +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' @@ -452,6 +457,30 @@ describe('balanceReplyIsCurrent', () => { }) }) +describe('balanceReplyDecision', () => { + 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') diff --git a/apps/dapp/lib/chains/balance-reply.ts b/apps/dapp/lib/chains/balance-reply.ts index b6805c7a..50c67007 100644 --- a/apps/dapp/lib/chains/balance-reply.ts +++ b/apps/dapp/lib/chains/balance-reply.ts @@ -35,6 +35,22 @@ export function balanceReplyIsCurrent(asked: BalanceAsk, now: BalanceAsk): boole ) } +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)) From 725033c1ff946055a4488db79d87f0aca9b87a4f Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:24:53 +0100 Subject: [PATCH 06/27] fix(dapp): show a disconnected person's balance reply instead of a wallet-changed notice --- apps/dapp/lib/__tests__/chain-balances.test.ts | 16 ++++++++++++++++ apps/dapp/lib/chains/balance-reply.ts | 7 +------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts index da97aaf9..7cd32bbd 100644 --- a/apps/dapp/lib/__tests__/chain-balances.test.ts +++ b/apps/dapp/lib/__tests__/chain-balances.test.ts @@ -458,6 +458,22 @@ describe('balanceReplyIsCurrent', () => { }) 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', () => { diff --git a/apps/dapp/lib/chains/balance-reply.ts b/apps/dapp/lib/chains/balance-reply.ts index 50c67007..c88f78cc 100644 --- a/apps/dapp/lib/chains/balance-reply.ts +++ b/apps/dapp/lib/chains/balance-reply.ts @@ -27,12 +27,7 @@ export interface BalanceAsk { /** 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 !== undefined && - asked.account === now.account && - asked.chain === now.chain && - asked.turn === now.turn - ) + 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.' From ff736b853563982685181b0e87165acda25b636e Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:37:49 +0100 Subject: [PATCH 07/27] feat(dapp): keep the chat until a new chat starts Leaving the intents page unmounted the chat and lost it. ChatHost now holds it in the chain layout from the first visit, hidden on other pages, so a race, a card or a payment being followed carries on. A per-wallet, per-chain transcript of words and explorer links brings the conversation back after a reload, read only. New chat clears both. Stellar's saved turns are untouched. --- apps/dapp/app/[chain]/intents/new/page.tsx | 20 +- apps/dapp/app/[chain]/intents/page.tsx | 22 +- apps/dapp/app/[chain]/layout.tsx | 5 +- apps/dapp/components/intents/chat-host.tsx | 72 ++++++ apps/dapp/components/intents/intent-chat.tsx | 253 ++++++++++++++++-- apps/dapp/lib/__tests__/live-session.test.ts | 258 +++++++++++++++++++ apps/dapp/lib/chat/live-session.ts | 128 +++++++++ 7 files changed, 695 insertions(+), 63 deletions(-) create mode 100644 apps/dapp/components/intents/chat-host.tsx create mode 100644 apps/dapp/lib/__tests__/live-session.test.ts create mode 100644 apps/dapp/lib/chat/live-session.ts 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/components/intents/chat-host.tsx b/apps/dapp/components/intents/chat-host.tsx new file mode 100644 index 00000000..3f6c2f83 --- /dev/null +++ b/apps/dapp/components/intents/chat-host.tsx @@ -0,0 +1,72 @@ +'use client' + +import { cn } from '@intent/ui' +import { usePathname, useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useRef, type ReactNode } from 'react' + +import { 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="Search banks"]') + ?.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/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 4de93d4a..f80a1e73 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -19,6 +19,15 @@ import { unclearMessage, } from '../../lib/chat/orchestrate-copy' import { rememberExchange } from '../../lib/chat/dialogue' +import { + appendLiveTurn, + chainOutcomeSentence, + clearLiveSession, + liveSessionStorage, + loadLiveSession, + ownedTurns, + 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' @@ -74,6 +83,7 @@ import { CashoutCard } from './cashout-card' import { CashoutCurrencyQuestion, type CashoutQuestionState } from './cashout-currency-question' import { cashoutChoices, + CURRENCY_QUESTION, NO_CASHOUTS, unsupportedCurrencyReply, unsupportedTokenReply, @@ -192,7 +202,35 @@ 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({ linkedRule }: { linkedRule?: string }): JSX.Element { const createIntent = useCreateIntent() const cancelIntent = useCancelIntent() const settleIntent = useSettleIntent() @@ -279,10 +317,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. @@ -327,11 +367,59 @@ export function IntentChat(): JSX.Element { const slippageEdit = strategy.slippageBps !== undefined ? { slippageBps: strategy.slippageBps } : {} - const { slug, descriptor } = useChain() + 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 + const transcriptOwnerRef = useRef(transcriptOwner) + transcriptOwnerRef.current = transcriptOwner + const transcript = restored === null ? ownedTurns(savedTurns, transcriptOwner) : [] + 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 = transcriptOwnerRef.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 // reminder left by a different wallet in this browser is never shown @@ -755,6 +843,82 @@ 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) { + 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 chainOutcome = chainOutcomeSentence({ + phase: chainAction.phase, + venueName: chainAction.prepared?.venueName, + error: chainAction.error, + }) + 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 ?? transcriptOwnerRef.current + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainOutcome]) + + 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' @@ -913,6 +1077,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, @@ -1443,6 +1608,7 @@ export function IntentChat(): JSX.Element { cashout.dismiss() chainAction.reset() setClarifyBusy(true) + const asked = latest.current.turn void fetchCashoutCurrencies(slug).then((weave) => { setClarifyBusy(false) const choices = cashoutChoices(slug, weave) @@ -1457,6 +1623,7 @@ export function IntentChat(): JSX.Element { choices, ...(route.amount !== undefined ? { amount: route.amount } : {}), }) + if (asked === latest.current.turn) recordLive('agent', CURRENCY_QUESTION) } }) return @@ -1550,6 +1717,15 @@ export function IntentChat(): JSX.Element { } const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet()) + 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 (cashoutRoute.kind !== 'pass') { startCashout(cashoutRoute, text) return @@ -1565,10 +1741,9 @@ export function IntentChat(): JSX.Element { if (offered.some((choice) => choice.code === named)) { answerCashoutQuestion(named, text) } else { - setCashoutQuestion({ - ...cashoutQuestion, - retry: { said: text, reply: unsupportedCurrencyReply(named, offered) }, - }) + const reply = unsupportedCurrencyReply(named, offered) + setCashoutQuestion({ ...cashoutQuestion, retry: { said: text, reply } }) + recordLive('agent', reply) } return } @@ -1618,7 +1793,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 @@ -1719,6 +1893,7 @@ export function IntentChat(): JSX.Element { if (answer.outcome === 'ask') { setClarifying({ turns: [...turns, { role: 'assistant', content: answer.question }] }) + if (asked.turn === latest.current.turn) recordLive('agent', answer.question) return } @@ -1830,6 +2005,10 @@ export function IntentChat(): JSX.Element { /** A clean slate: the open intent, the turns before it and any notes. */ function newChat(): void { latest.current.turn += 1 + if (transcriptOwner !== undefined) { + clearLiveSession(liveSessionStorage(), segment, transcriptOwner) + } + setSavedTurns(null) handleReset() setThread([]) setNotes([]) @@ -2370,7 +2549,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 @@ -2401,22 +2587,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. */} @@ -2544,9 +2735,11 @@ export function IntentChat(): JSX.Element { {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 ? ( @@ -2633,6 +2826,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, @@ -2767,6 +2961,7 @@ export function IntentChat(): JSX.Element { { + recordStellarTx('The plan settled.', hash) if (turnId !== null) { updateTurn(turnId, { txHash: hash }) setTurns(loadTurns(slug)) @@ -2781,6 +2976,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 @@ -2884,6 +3080,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, @@ -2912,7 +3109,7 @@ export function IntentChat(): JSX.Element { > () + 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() + }) +}) diff --git a/apps/dapp/lib/chat/live-session.ts b/apps/dapp/lib/chat/live-session.ts new file mode 100644 index 00000000..de49f7d2 --- /dev/null +++ b/apps/dapp/lib/chat/live-session.ts @@ -0,0 +1,128 @@ +/** + * 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 + +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 + } +} + +/** 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, 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. + } +} + +/** 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 (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 +} From 15d23c1949041deb6c00ddcfb74d65dcf47a8077 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:50:24 +0100 Subject: [PATCH 08/27] fix(dapp): start a switched wallet fresh and keep private figures out of the transcript Another wallet connecting now resets the open chat, or waits with a notice while a signature is open, recording to the signer until it ends. A chain card or position left open is refused on return once stale. Stored text is masked and capped, signature notices are not stored, and a reset shows the saved turns as a reload would. --- apps/dapp/components/intents/chat-host.tsx | 8 +- apps/dapp/components/intents/intent-chat.tsx | 159 +++++++++++++++++-- apps/dapp/lib/__tests__/live-session.test.ts | 135 ++++++++++++++++ apps/dapp/lib/chat/live-session.ts | 51 +++++- 4 files changed, 335 insertions(+), 18 deletions(-) diff --git a/apps/dapp/components/intents/chat-host.tsx b/apps/dapp/components/intents/chat-host.tsx index 3f6c2f83..e74ad6be 100644 --- a/apps/dapp/components/intents/chat-host.tsx +++ b/apps/dapp/components/intents/chat-host.tsx @@ -4,7 +4,7 @@ import { cn } from '@intent/ui' import { usePathname, useSearchParams } from 'next/navigation' import { Suspense, useEffect, useRef, type ReactNode } from 'react' -import { showChatOnPath } from '../../lib/chat/live-session' +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' @@ -12,7 +12,9 @@ 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 + return ( + + ) } /** @@ -39,7 +41,7 @@ export function ChatHost({ children }: { children: ReactNode }): JSX.Element { } // The bank list is drawn on the body, which `hidden` cannot reach. Escape is its own way out. document - .querySelector('input[aria-label="Search banks"]') + .querySelector(`input[aria-label="${BANK_SEARCH_LABEL}"]`) ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) }, [showing]) diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index f80a1e73..c90b1acf 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -21,11 +21,16 @@ import { 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' @@ -76,6 +81,7 @@ import { chainCapabilities } from '../../lib/chains/capabilities' import { chainHelp } from '../../lib/chains/explain' import { looksLikeChainAction, parseChainAction } from '../../lib/chains/parse-action' import type { ChainActionRequest, WalletBalances } from '../../lib/chains/types' +import { QUOTE_EXPIRED } from '../../lib/chains/signing-flow' import { useChainAction } from '../../hooks/use-chain-action' import { ChainActionConfirm } from './chain-action-confirm' import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout' @@ -230,7 +236,14 @@ function SavedTurns({ turns }: { turns: LiveTurn[] }): JSX.Element { ) } -export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element { +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() @@ -377,9 +390,12 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element // 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 - const transcriptOwnerRef = useRef(transcriptOwner) - transcriptOwnerRef.current = transcriptOwner + // 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 @@ -397,7 +413,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element role: LiveTurn['role'], text: string, explorerUrl?: string, - account: string | undefined = transcriptOwnerRef.current + account: string | undefined = chatOwner.current ): void { if (account === undefined) return appendLiveTurn(liveSessionStorage(), segment, account, { @@ -846,7 +862,12 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element // 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) { + if ( + affordError !== null && + message !== null && + restored === null && + recordableReply(affordError) + ) { recordLive('agent', affordError) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -879,7 +900,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element 'agent', chainOutcome, chainAction.phase === 'settled' ? chainAction.explorerUrl : undefined, - chainAction.account ?? transcriptOwnerRef.current + chainAction.account ?? chatOwner.current ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [chainOutcome]) @@ -968,6 +989,91 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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. + const signatureOpen = + executionInFlight || + cashout.locked || + (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 + } + 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 @@ -1110,7 +1216,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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]) @@ -1224,6 +1330,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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 { @@ -1233,6 +1340,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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?` @@ -1383,6 +1491,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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 @@ -1399,6 +1508,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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' @@ -1572,7 +1682,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element /** 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) @@ -1609,8 +1719,10 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element 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) { @@ -1655,7 +1767,9 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element chainAction.refuse(unsupportedTokenReply(route.token), 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) }) } @@ -1712,7 +1826,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element // 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 } @@ -1815,7 +1929,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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( @@ -1880,6 +1994,8 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element 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 } @@ -2004,14 +2120,20 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element /** A clean slate: the open intent, the turns before it and any notes. */ function newChat(): void { - latest.current.turn += 1 - if (transcriptOwner !== undefined) { + // 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) @@ -2045,6 +2167,15 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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 } + ) } /** @@ -2632,7 +2763,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): JSX.Element ) : null} {/* The turns before this one, quieter than the open intent. */} - {thread.map((turn) => ( + {thread.slice(inSaved.thread).map((turn) => (
{turn.text}
@@ -2710,7 +2841,7 @@ export function IntentChat({ linkedRule }: { linkedRule?: string }): 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}
diff --git a/apps/dapp/lib/__tests__/live-session.test.ts b/apps/dapp/lib/__tests__/live-session.test.ts index d2c7ea5c..d9e8677c 100644 --- a/apps/dapp/lib/__tests__/live-session.test.ts +++ b/apps/dapp/lib/__tests__/live-session.test.ts @@ -1,13 +1,22 @@ +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' @@ -256,3 +265,129 @@ describe('the one sentence a chain race settles to', () => { 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('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/chat/live-session.ts b/apps/dapp/lib/chat/live-session.ts index de49f7d2..0cb55d50 100644 --- a/apps/dapp/lib/chat/live-session.ts +++ b/apps/dapp/lib/chat/live-session.ts @@ -1,3 +1,5 @@ +import { accountMismatch } from '../weave/cashout-view' + /** * What was said in the chat, kept until New chat. * @@ -15,6 +17,7 @@ export interface LiveTurn { 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}` @@ -38,6 +41,14 @@ function httpsUrl(value: unknown): string | 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 @@ -45,7 +56,7 @@ function toLiveTurn(value: unknown): LiveTurn | undefined { 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, at, ...(link !== undefined ? { explorerUrl: link } : {}) } + return { role, text: storedText(text), at, ...(link !== undefined ? { explorerUrl: link } : {}) } } export function loadLiveSession( @@ -95,6 +106,44 @@ export function clearLiveSession( } } +/** + * 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. Finish the open signature first.' +export const FINISH_SIGNATURE_NOTICE = + 'Finish or cancel the signature first, then continue the chat.' + +/** Notices about a signature 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, From 53ba04bef602fcd7c92fd306fbe27af64d8566cd Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:52:54 +0100 Subject: [PATCH 09/27] fix(dapp): make the wallet switch notice true after a cash-out is paid --- apps/dapp/lib/__tests__/live-session.test.ts | 6 ++++++ apps/dapp/lib/chat/live-session.ts | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/dapp/lib/__tests__/live-session.test.ts b/apps/dapp/lib/__tests__/live-session.test.ts index d9e8677c..ffa6cb3d 100644 --- a/apps/dapp/lib/__tests__/live-session.test.ts +++ b/apps/dapp/lib/__tests__/live-session.test.ts @@ -297,6 +297,12 @@ describe('what is said without being a reply', () => { 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) }) diff --git a/apps/dapp/lib/chat/live-session.ts b/apps/dapp/lib/chat/live-session.ts index 0cb55d50..36841630 100644 --- a/apps/dapp/lib/chat/live-session.ts +++ b/apps/dapp/lib/chat/live-session.ts @@ -121,11 +121,12 @@ export function ownerChange( return busy ? 'defer' : 'reset' } -export const WALLET_SWITCHED_NOTICE = 'Your wallet changed. Finish the open signature first.' +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 signature answer nothing the person said, so a reload would show them alone. */ +/** 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 } From 6d3a4fe4ec8099c3923dec6b4ed5f205879d201c Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:02:15 +0100 Subject: [PATCH 10/27] feat(dapp): read a swap and what comes after it --- apps/dapp/app/api/agents/compete/route.ts | 13 +- .../dapp/lib/__tests__/chain-sequence.test.ts | 278 ++++++++++++++++++ .../lib/__tests__/weave-parse-cashout.test.ts | 6 +- apps/dapp/lib/chains/parse-action.ts | 2 +- apps/dapp/lib/chains/parse-sequence.ts | 75 +++++ apps/dapp/lib/chains/prepare.ts | 81 +++++ apps/dapp/lib/chains/sequence.ts | 84 ++++++ apps/dapp/lib/weave/cashout-view.ts | 5 +- apps/dapp/lib/weave/parse-cashout.ts | 25 +- 9 files changed, 559 insertions(+), 10 deletions(-) create mode 100644 apps/dapp/lib/__tests__/chain-sequence.test.ts create mode 100644 apps/dapp/lib/chains/parse-sequence.ts create mode 100644 apps/dapp/lib/chains/sequence.ts 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/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts new file mode 100644 index 00000000..c588e4e6 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' + +import { assertNativeReserve } from '../chains/prepare' +import { parseChainSequence } 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.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('routeCashout with an amount', () => { + 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' }) + }) + + 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')).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('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') + }) +}) + +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 is held and that nothing was paid out', () => { + const reply = strandedReply('USDC', '5') + expect(reply).toMatch(/you are holding/) + expect(reply).toMatch(/nothing was paid out/) + expect(reply).toMatch(/5/) + }) + + it('names no number when the amount is unknown', () => { + expect(strandedReply('USDC', undefined)).not.toMatch(/\d/) + }) +}) + +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('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/) + }) +}) diff --git a/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts index effa8018..22adf517 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([ @@ -199,7 +201,7 @@ describe('routeCashout', () => { }) 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/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index fac32b39..c8d874dc 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -23,7 +23,7 @@ const ACTION_VERB = /\b(swap|convert|exchange|trade|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i /** Plain decimal text: no separators, and a leading zero on ".5", which nothing downstream reads. */ -function amountOf(text: string): string { +export function amountOf(text: string): string { const plain = text.replace(/,/g, '') return plain.startsWith('.') ? `0${plain}` : plain } diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts new file mode 100644 index 00000000..79dd3911 --- /dev/null +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -0,0 +1,75 @@ +import { parseCashout } from '../weave/parse-cashout' +import { amountOf } from './parse-action' +import type { ChainSequenceRequest, FollowOn } from './sequence' + +const AMOUNT = String.raw`\$?([\d,]*\.?\d+)` +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 +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) 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 tokenOut = match[3] 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: { + kind: 'swap', + amount: amountOf(match[1] as string), + tokenIn: match[2] as string, + tokenOut, + venue: undefined, + }, + followOn, + } +} diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index f78877a0..13f10bf9 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -1,12 +1,15 @@ import { CHAIN_DESCRIPTORS } from '@intent/config' import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade-cap' +import { WRAP_NEAR } from './near/tokens' +import { fromBase, toBase } from './sequence' import { ChainActionRefused, tokenBySymbol, type ChainActionRequest, type ChainModule, type ChainToken, + type ChainWithActions, type ChainVenue, type PreparedAction, } from './types' @@ -142,9 +145,87 @@ export async function prepareChainAction( const tokenIn = assertKnownTokens(chainApi, request) await assertAmountWithinCap(chainApi, request, tokenIn, env) + await assertNativeReserve(chainApi, request, 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 + +/** + * 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 controller = new AbortController() + let timer: ReturnType | undefined + let reading + try { + reading = 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 + } finally { + if (timer !== undefined) clearTimeout(timer) + } + + 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/sequence.ts b/apps/dapp/lib/chains/sequence.ts new file mode 100644 index 00000000..b1357c56 --- /dev/null +++ b/apps/dapp/lib/chains/sequence.ts @@ -0,0 +1,84 @@ +import type { CashoutCurrencyCode } from '../weave/parse-cashout' +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. */ +export function cashoutAmountFrom(delivered: string): string { + const places = delivered.includes('.') ? (delivered.split('.')[1] ?? '').length : 0 + const base = toBase(delivered, places) + if (base === undefined) return '0' + const hundredths = (base * BigInt(10000)) / (BigInt(102) * BigInt(10) ** BigInt(places)) + return fromBase(hundredths, 2) +} + +/** The sentence for a follow-on that cannot run on this chain, or nothing if it can. */ +export function followOnRefusal( + chain: ChainWithActions, + chainName: string, + followOn: FollowOn, + venues: readonly Pick[] +): 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 undefined + if (!venues.some((v) => v.kinds.includes(followOn.kind))) { + return `Nothing on ${chainName} can ${followOn.kind} yet. ${NOTHING_MOVED}` + } + return undefined +} + +/** After the swap went through and the follow-on cannot go on: what the person holds now. */ +export function strandedReply(symbol: string, amount: string | undefined): string { + const held = amount === undefined ? symbol : `about ${amount} ${symbol}` + return `The swap went through and you are holding ${held}, but nothing was paid out. Check your wallet before doing anything else.` +} diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index 3ab144cc..648fe6d9 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/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index 21dbf64c..40caf893 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -1,3 +1,4 @@ +import { SEQUENCE_CASHOUT_CHAINS } from '../chains/sequence' import { isCashoutChain } from './types' export type CashoutCurrencyCode = 'NGN' | 'KES' | 'UGX' | 'TZS' @@ -10,7 +11,7 @@ export interface CashoutRequest { export type CashoutRead = | CashoutRequest - | { unsupportedToken: string; currency?: CashoutCurrencyCode } + | { unsupportedToken: string; amount?: string; currency?: CashoutCurrencyCode } | { unsupportedCurrency: string } export type CashoutRoute = @@ -19,6 +20,8 @@ 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; currency?: CashoutCurrencyCode } | { kind: 'currency'; currency: string } const CURRENCY_WORDS: Record = { @@ -194,7 +197,9 @@ export function parseCashout(text: string): CashoutRead | null { if (token !== undefined && token !== 'usdc' && !dollarAmount) { if (isFiatWord(token)) return null if (amount === undefined && !KNOWN_TOKENS.has(token)) return null - return { unsupportedToken: token.toUpperCase(), ...withCurrency } + // A dollar figure is not an amount of this token. + const ofToken = amount !== undefined && !dollar ? { amount } : {} + return { unsupportedToken: token.toUpperCase(), ...ofToken, ...withCurrency } } if (token === undefined && amount !== undefined && !dollar) return null @@ -248,9 +253,19 @@ export function routeCashout( : { kind: 'currency', currency: read.unsupportedCurrency } } if ('unsupportedToken' in read) { - return chain === 'stellar' && !weaveOnly(read.currency) - ? { kind: 'pass' } - : { kind: 'token', token: read.unsupportedToken } + if (chain === 'stellar' && !weaveOnly(read.currency)) return { kind: 'pass' } + if ( + read.amount !== undefined && + (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) + ) { + return { + kind: 'sequence', + token: read.unsupportedToken, + amount: read.amount, + ...(read.currency !== undefined ? { currency: read.currency } : {}), + } + } + return { kind: 'token', token: read.unsupportedToken } } if (read.currency === undefined) { if (chain === 'stellar' && !stellarMainnet) return { kind: 'pass' } From a820022e5c306b79e88f3962de565c795bf583e6 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:08:16 +0100 Subject: [PATCH 11/27] fix(dapp): keep a refused currency, say what reached the wallet, and read only real addresses --- .../dapp/lib/__tests__/chain-sequence.test.ts | 96 +++++++++++++++++-- apps/dapp/lib/chains/parse-sequence.ts | 6 +- apps/dapp/lib/chains/sequence.ts | 26 +++-- apps/dapp/lib/weave/parse-cashout.ts | 4 + 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index c588e4e6..ea916454 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -57,6 +57,40 @@ describe('parseChainSequence', () => { }) }) +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.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('becomes a sequence on chains that can finish it', () => { expect(routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'solana')).toEqual({ @@ -114,6 +148,13 @@ describe('deliveredAmount', () => { 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() @@ -126,6 +167,14 @@ 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() }) }) @@ -171,15 +220,27 @@ describe('followOnRefusal', () => { }) describe('strandedReply', () => { - it('says what is held and that nothing was paid out', () => { - const reply = strandedReply('USDC', '5') - expect(reply).toMatch(/you are holding/) - expect(reply).toMatch(/nothing was paid out/) - expect(reply).toMatch(/5/) + 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', () => { - expect(strandedReply('USDC', undefined)).not.toMatch(/\d/) + 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. 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/) }) }) @@ -248,6 +309,29 @@ describe('assertNativeReserve', () => { 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', diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index 79dd3911..a68891d7 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -14,6 +14,8 @@ 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 { @@ -39,7 +41,9 @@ function followOnOf(text: string): FollowOn | { refusal: string } | null { if (stake !== null) return stake[1] === undefined ? { kind: 'stake' } : { kind: 'stake', venue: stake[1].trim() } const send = SEND.exec(text) - if (send !== null) return { kind: 'send', recipient: send[1] as string } + if (send !== null && ADDRESS_CHARS.test(send[1] as string)) { + return { kind: 'send', recipient: send[1] as string } + } return null } diff --git a/apps/dapp/lib/chains/sequence.ts b/apps/dapp/lib/chains/sequence.ts index b1357c56..7b044a00 100644 --- a/apps/dapp/lib/chains/sequence.ts +++ b/apps/dapp/lib/chains/sequence.ts @@ -45,13 +45,16 @@ export function deliveredAmount( 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. */ -export function cashoutAmountFrom(delivered: string): string { +/** + * 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 '0' + if (base === undefined) return undefined const hundredths = (base * BigInt(10000)) / (BigInt(102) * BigInt(10) ** BigInt(places)) - return fromBase(hundredths, 2) + 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. */ @@ -77,8 +80,15 @@ export function followOnRefusal( return undefined } -/** After the swap went through and the follow-on cannot go on: what the person holds now. */ -export function strandedReply(symbol: string, amount: string | undefined): string { - const held = amount === undefined ? symbol : `about ${amount} ${symbol}` - return `The swap went through and you are holding ${held}, but nothing was paid out. Check your wallet before doing anything else.` +/** 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. 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/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index 40caf893..00daaa3d 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -197,6 +197,10 @@ export function parseCashout(text: string): CashoutRead | null { if (token !== undefined && token !== 'usdc' && !dollarAmount) { if (isFiatWord(token)) return null if (amount === undefined && !KNOWN_TOKENS.has(token)) return null + // A fiat we don't pay is said, not dropped: the token case would lose it. + if (target !== undefined && currency === undefined && !namesNoCurrency) { + return { unsupportedCurrency: OTHER_FIAT[target] as string } + } // A dollar figure is not an amount of this token. const ofToken = amount !== undefined && !dollar ? { amount } : {} return { unsupportedToken: token.toUpperCase(), ...ofToken, ...withCurrency } From 0128bc6326f722bd7a8302231bedaedf543bb3ea Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:29:15 +0100 Subject: [PATCH 12/27] feat(dapp): swap then cash out on solana, sui and avalanche "cash out 5 SOL" and "swap 5 SOL to USDC then cash out" now run the ordinary race for a swap to USDC, then measure from the chain what arrived and offer the cash-out on a press. A pending step 1, or a wallet error after sending, never reaches step 2 and tells the person to check their wallet. A clean failure ends plainly and can be retried from the same race. A failed balance read is never guessed at. Supply, stake and send after a swap are refused as not yet available before anything is signed. --- .../intents/cashout-currency-question.tsx | 3 + .../intents/chain-sequence-steps.tsx | 116 ++++ apps/dapp/components/intents/intent-chat.tsx | 159 ++++- apps/dapp/hooks/use-chain-sequence.ts | 211 ++++++ .../lib/__tests__/chain-sequence-flow.test.ts | 598 ++++++++++++++++++ apps/dapp/lib/chains/sequence-flow.ts | 383 +++++++++++ 6 files changed, 1467 insertions(+), 3 deletions(-) create mode 100644 apps/dapp/components/intents/chain-sequence-steps.tsx create mode 100644 apps/dapp/hooks/use-chain-sequence.ts create mode 100644 apps/dapp/lib/__tests__/chain-sequence-flow.test.ts create mode 100644 apps/dapp/lib/chains/sequence-flow.ts diff --git a/apps/dapp/components/intents/cashout-currency-question.tsx b/apps/dapp/components/intents/cashout-currency-question.tsx index 7d9101f1..89d5dd95 100644 --- a/apps/dapp/components/intents/cashout-currency-question.tsx +++ b/apps/dapp/components/intents/cashout-currency-question.tsx @@ -2,6 +2,7 @@ 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' @@ -9,6 +10,8 @@ import { agentBubble, userBubble } from './bubble' export interface CashoutQuestionState { amount?: string choices: CashoutCurrency[] + /** A swap waiting on this answer: answering starts its race, not the form. */ + sequence?: ChainSequenceRequest /** 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. */ 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..e651500a --- /dev/null +++ b/apps/dapp/components/intents/chain-sequence-steps.tsx @@ -0,0 +1,116 @@ +'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, 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 ? cashoutAmountFrom(delivered) : undefined + + return ( +
+ +
    +
  1. + + {first} +
  2. +
  3. + + + {second} + +
  4. +
+ + {stage === 'swap' ? ( +

+ Step 2 waits for your press. It uses what the swap actually delivers. +

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

+

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

+ {deliveredLine(delivered)} +

+ ) : null} + + {offer !== 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/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index c90b1acf..e7dd359b 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -80,10 +80,22 @@ import { balanceReplyDecision, chainBalanceReply } from '../../lib/chains/balanc import { chainCapabilities } from '../../lib/chains/capabilities' import { chainHelp } from '../../lib/chains/explain' import { looksLikeChainAction, parseChainAction } from '../../lib/chains/parse-action' -import type { ChainActionRequest, WalletBalances } from '../../lib/chains/types' +import { parseChainSequence } from '../../lib/chains/parse-sequence' +import { followOnRefusal, type ChainSequenceRequest } from '../../lib/chains/sequence' +import { + CASHOUT_UNDER_WAY, + cashoutSequence, + currencyStep, + endedNote, + followOnNotYet, + sequenceOutcome, +} 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' @@ -639,6 +651,15 @@ export function IntentChat({ }, [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) + // What a new message left behind by ending a swap that had settled. + const [sequenceNote, setSequenceNote] = useState(null) + useEffect(() => { + chainSequence.clear() + setSequenceNote(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() @@ -905,6 +926,22 @@ export function IntentChat({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chainOutcome]) + const sequenceLine = sequenceOutcome( + chainSequence.stage, + chainSequence.delivered, + chainSequence.error + ) + useEffect(() => { + if (sequenceLine === undefined) return + 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 @@ -993,10 +1030,12 @@ export function IntentChat({ // 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. + // would lose the only way to finish it. So does a chain swap still being measured: + // it ends by itself, and `ready` after it is only a button the new wallet can't use. 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) @@ -1009,6 +1048,7 @@ export function IntentChat({ 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 = [] @@ -1679,6 +1719,78 @@ export function IntentChat({ })() } + /** Ends a chain sequence where it stands. Its USDC stays the person's, and they are told so. */ + function leaveSequence(): void { + const note = endedNote(chainSequence.stage, chainSequence.delivered, chainSequence.sequence) + setSequenceNote(note ?? null) + if (note !== undefined) { + recordLive('agent', note, undefined, chainSequence.account ?? chatOwner.current) + } + chainSequence.clear() + } + + /** Step 1: the same race as a plain swap, with the sequence watching its card. */ + function runSequenceSwap(request: ChainSequenceRequest, text: string): void { + 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 }) + } + + /** 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] })) + ) ?? + followOnNotYet(followOn) + if (refusal !== undefined) { + chainAction.refuse(refusal, text) + return + } + if (followOn.kind !== 'cashout') { + 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 }) + 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) { @@ -1707,6 +1819,11 @@ export function IntentChat({ supply.reset() perp.reset() + if (route.kind === 'sequence') { + startChainSequence(cashoutSequence(route), text) + return + } + if (route.kind === 'ask') { if (cashout.locked) { chainAction.refuse( @@ -1807,6 +1924,16 @@ export function IntentChat({ const asked = cashoutQuestion if (asked === null) return setCashoutQuestion({ ...asked, answer: said }) + if (asked.sequence !== undefined) { + runSequenceSwap( + { + ...asked.sequence, + followOn: { kind: 'cashout', currency: currency as CashoutCurrencyCode }, + }, + message ?? said + ) + return + } payOutIn(currency, asked.amount, message ?? said) } @@ -1838,7 +1965,10 @@ export function IntentChat({ executionInFlight && (cashoutRoute.kind !== 'pass' || (clarifying === null && open && classifyFollowUp(text) === 'note')) - if (!refused) recordLive('user', text) + if (!refused) { + recordLive('user', text) + leaveSequence() + } if (cashoutRoute.kind !== 'pass') { startCashout(cashoutRoute, text) @@ -1872,6 +2002,17 @@ export function IntentChat({ // question, still goes to the orchestrator. const capabilities = chainCapabilities(slug) if (capabilities !== undefined) { + const sequenceRead = parseChainSequence(text) + if (sequenceRead !== null) { + if ('refusal' in sequenceRead) { + setMessage(text) + clearChainRace() + chainAction.refuse(sequenceRead.refusal, text) + } else { + startChainSequence(sequenceRead, text) + } + return + } const action = parseChainAction(text) if (action !== null) { // The thread only renders once there is a message, so without this the @@ -2060,6 +2201,8 @@ export function IntentChat({ // 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') { + // A swap must not start under whatever was said while this was being read. + if (writtenOut.kind === 'sequence' && asked.turn !== latest.current.turn) return startCashout(writtenOut, turns[0]?.content ?? text) return } @@ -2145,6 +2288,8 @@ export function IntentChat({ // 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) @@ -2628,6 +2773,7 @@ export function IntentChat({ turns={listedTurns} onSelect={(turn) => { if (chainSigning(chainAction.phase)) return + leaveSequence() // A race on screen ends when another turn is opened. if (chainRace !== null) { clearChainRace() @@ -2863,6 +3009,11 @@ export function IntentChat({ ) ) : null} + {sequenceNote !== null && restored === null ? ( +
+
{sequenceNote}
+
+ ) : null} {cashoutQuestion !== null ? ( : null} + {restored === null ? : null} + {showDirectNote(directFallback, chainAction.phase) ? (

The agents were unavailable, so this is the direct route. Nothing has been signed. diff --git a/apps/dapp/hooks/use-chain-sequence.ts b/apps/dapp/hooks/use-chain-sequence.ts new file mode 100644 index 00000000..053b8fde --- /dev/null +++ b/apps/dapp/hooks/use-chain-sequence.ts @@ -0,0 +1,211 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { ChainSequenceRequest } from '../lib/chains/sequence' +import { + beforeReadCounts, + measureDelivered, + moveOnCard, + outputToken, + readHolding, + readyAction, + stageOnForm, + unmeasuredReply, + 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 the output token's balance was before the swap. Anything but `known` strands at settled. */ +type Before = + | { status: 'unknown' } + | { status: 'reading' } + | { status: 'known'; amount: string; token: ChainToken; account: 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 + 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. + */ +export function useChainSequence( + chainAction: ChainAction, + cashout: Pick +): ChainSequence { + const { slug } = useChain() + const { address } = useWallet() + const [run, setRun] = useState(null) + + // 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 + // Read when a `before` answers, which is some way on from when it was asked. + const phaseNow = useRef(phase) + phaseNow.current = phase + + 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 + + // Before any signature, each time a card is put on offer: a wallet declined comes back here too. + useEffect(() => { + if (request === undefined || phase !== 'review') return + if (prepared === undefined || builtFor === undefined) return + beforeRead.current += 1 + const mine = beforeRead.current + const token = outputToken(slug, prepared.receive?.symbol, request.swap.tokenOut) + if (token === undefined) { + before.current = UNKNOWN + return + } + const account = builtFor + const family = prepared.signing.family + before.current = { status: 'reading' } + void readHolding(slug, account, token).then((amount) => { + if (mine !== beforeRead.current) return + before.current = + amount !== undefined && beforeReadCounts(phaseNow.current, family) + ? { status: 'known', amount, token, account } + : UNKNOWN + }) + }, [request, phase, prepared, builtFor, slug]) + + useEffect(() => { + setRun((r) => { + if (r === null) return r + 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 } : {}), + } + }) + }, [phase, signed]) + + // 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 + before.current = UNKNOWN + setRun({ request: next, stage: 'swap', settled: false }) + }, []) + + const clear = useCallback(() => { + beforeRead.current += 1 + before.current = UNKNOWN + setRun(null) + }, []) + + const runNext = useCallback(() => { + if (run?.stage !== 'ready' || run.delivered === undefined || run.account === undefined) return + const { followOn } = run.request + // Step 2 by kind. A cash-out is the only one so far: `followOnNotYet` turns the rest away + // before step 1, and each goes here when it is built. + if (followOn.kind !== 'cashout') 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]) + + return { + sequence: run?.request ?? null, + stage, + delivered: run?.delivered, + error: run?.error, + settled: run?.settled ?? false, + account: run?.account, + start, + runNext, + clear, + } +} 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..d45a5c7e --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -0,0 +1,598 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + CASHOUT_UNDER_WAY, + CASHOUT_NOT_STARTED, + MEASURE_ATTEMPTS, + MEASURE_INTERVAL_MS, + UNCONFIRMED_SWAP, + beforeReadCounts, + cashoutSequence, + currencyStep, + deliveredLine, + endedNote, + followOnNotYet, + holdingOf, + measureDelivered, + moveOnCard, + outputToken, + readHolding, + readyAction, + sequenceOutcome, + stageAfterMeasure, + stageAfterSwap, + stageOnCard, + stageOnForm, + stepLabels, + stepMarks, + 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('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('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 still on offer', () => { + expect(beforeReadCounts('review', 'solana')).toBe(true) + expect(beforeReadCounts('review', 'evm')).toBe(true) + }) + + 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', 'building'] 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('followOnNotYet', () => { + it('lets a cash-out through', () => { + expect(followOnNotYet({ kind: 'cashout' })).toBeUndefined() + }) + + it.each([ + [{ kind: 'supply' as const }], + [{ kind: 'stake' as const }], + [{ kind: 'send' as const, recipient: ALICE }], + ])('says not yet for %j, with nothing signed', (followOn) => { + expect(followOnNotYet(followOn)).toMatch(/not yet/i) + expect(followOnNotYet(followOn)).toContain('Nothing was signed or sent.') + }) +}) diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts new file mode 100644 index 00000000..c0a5ef29 --- /dev/null +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -0,0 +1,383 @@ +import type { ChainActionPhase } from '../../hooks/use-chain-action' +import { + NO_CASHOUTS, + accountMismatch, + unsupportedCurrencyReply, + walletSends, + type CashoutCurrency, +} from '../weave/cashout-view' +import type { CashoutCurrencyCode, CashoutRoute } from '../weave/parse-cashout' +import { AVALANCHE_TOKENS, MONAD_TOKENS } from './evm/tokens' +import { nearTokens } from './near/tokens' +import { + cashoutAmountFrom, + deliveredAmount, + strandedReply, + type ChainSequenceRequest, + type FollowOn, +} from './sequence' +import { SOLANA_TOKENS } from './solana/tokens' +import { SUI_TOKENS } from './sui/tokens' +import type { + ChainToken, + ChainWithActions, + SigningPayload, + TokenBalance, + 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. + */ +export type SequenceStage = 'swap' | 'measuring' | 'ready' | 'next' | 'stranded' | 'done' + +export const MEASURE_INTERVAL_MS = 3_000 +export const MEASURE_ATTEMPTS = 20 + +/** 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.' + +/** 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." + +/** 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.' + +/** + * 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 +} + +/** + * 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: UNCONFIRMED_SWAP } + if (stage === 'done') return { stage, settled: false, error: CASHOUT_NOT_STARTED } + return { stage, settled: false } +} + +/** + * 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 + return TOKEN_TABLES[chain as ChainWithActions]?.find((t) => t.symbol === 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) + } +} + +/** + * Whether a `before` read that just answered is still from before the swap. It was asked at review, + * so it holds while the card 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'] +): boolean { + if (phase === 'review') return true + return phase === 'signing' && !walletSends(family) +} + +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 reached the wallet, and whether that is enough to cash out. */ +export function deliveredLine(delivered: string): string { + 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 +): string | undefined { + if (stage === 'ready' && delivered !== undefined) return deliveredLine(delivered) + return stage === 'stranded' || stage === 'done' ? error : undefined +} + +function outputSymbol(request: ChainSequenceRequest): string { + return (request.swap.tokenOut ?? '').toUpperCase() +} + +/** Said when a swap that settled is left before step 2 ran, or nothing where there is no news. */ +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') return undefined + return strandedReply(outputSymbol(request), delivered, request.followOn) +} + +/** 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} ${tokenIn.toUpperCase()} to ${outputSymbol(request)}` + const { followOn } = request + return [ + first, + followOn.kind === 'cashout' && followOn.currency !== undefined + ? `Cash out to ${followOn.currency}` + : 'Cash out', + ] +} + +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') return ['done', 'stopped'] + 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, + 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 } +} + +/** + * The follow-ons this build can finish. Refused before step 1, like any follow-on that cannot run. + * A cash-out is the only one yet: supply, stake and send arrive with `runNext` learning them. + */ +export function followOnNotYet(followOn: FollowOn): string | undefined { + return followOn.kind === 'cashout' + ? undefined + : 'A swap followed by a deposit, a stake or a send is not yet available here. Nothing was signed or sent.' +} From a67ea86ca592dc3d9bdc7600694bd90b71bbadaf Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:56:03 +0100 Subject: [PATCH 13/27] fix(dapp): check a chain sequence's words, balance and size before the swap is signed A cash-out the model wrote out runs only if the person's own words carry its amount and token. The balance before the swap is read from when the card starts building, once more on failure, and the card says plainly when the cash-out would not follow. A size Weave would refuse is said before signing, from a quote that makes no order. A currency question ends on a chain switch and "cash out to KES" answers it. Balance reads get their own limit. --- apps/dapp/.env.example | 1 + .../app/api/chain/[chain]/balances/route.ts | 2 +- .../intents/cashout-currency-question.tsx | 4 +- .../intents/chain-sequence-steps.tsx | 4 +- apps/dapp/components/intents/intent-chat.tsx | 66 +++- apps/dapp/hooks/use-chain-sequence.ts | 118 +++++-- .../lib/__tests__/chain-sequence-flow.test.ts | 297 +++++++++++++++++- .../dapp/lib/__tests__/chain-sequence.test.ts | 2 +- apps/dapp/lib/__tests__/rate-limit.test.ts | 18 +- .../__tests__/route-rate-limit-get.test.ts | 12 + apps/dapp/lib/chains/sequence-flow.ts | 190 ++++++++++- apps/dapp/lib/chains/sequence.ts | 2 +- apps/dapp/lib/server/rate-limit.ts | 11 +- apps/dapp/lib/weave/orders.ts | 3 +- apps/dapp/lib/weave/types.ts | 3 + 15 files changed, 660 insertions(+), 73 deletions(-) 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/api/chain/[chain]/balances/route.ts b/apps/dapp/app/api/chain/[chain]/balances/route.ts index 3f1c100f..2ebc8c11 100644 --- a/apps/dapp/app/api/chain/[chain]/balances/route.ts +++ b/apps/dapp/app/api/chain/[chain]/balances/route.ts @@ -14,7 +14,7 @@ export async function GET( request: Request, { params }: { params: Promise<{ chain: string }> } ): Promise { - const limited = await enforceRateLimit(request, 'build') + const limited = await enforceRateLimit(request, 'read') if (limited !== undefined) return limited const chainApi = chainModule((await params).chain) diff --git a/apps/dapp/components/intents/cashout-currency-question.tsx b/apps/dapp/components/intents/cashout-currency-question.tsx index 89d5dd95..2903a709 100644 --- a/apps/dapp/components/intents/cashout-currency-question.tsx +++ b/apps/dapp/components/intents/cashout-currency-question.tsx @@ -10,8 +10,8 @@ import { agentBubble, userBubble } from './bubble' export interface CashoutQuestionState { amount?: string choices: CashoutCurrency[] - /** A swap waiting on this answer: answering starts its race, not the form. */ - sequence?: ChainSequenceRequest + /** 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. */ diff --git a/apps/dapp/components/intents/chain-sequence-steps.tsx b/apps/dapp/components/intents/chain-sequence-steps.tsx index e651500a..bbff2a43 100644 --- a/apps/dapp/components/intents/chain-sequence-steps.tsx +++ b/apps/dapp/components/intents/chain-sequence-steps.tsx @@ -73,9 +73,7 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele {stage === 'swap' ? ( -

- Step 2 waits for your press. It uses what the swap actually delivers. -

+

{steps.swapLine}

) : null} {stage === 'measuring' ? ( diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index e7dd359b..ce0c6fac 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -88,7 +88,10 @@ import { currencyStep, endedNote, followOnNotYet, + questionAnswerFrom, + questionChainRefusal, sequenceOutcome, + sequenceWordsMatch, } from '../../lib/chains/sequence-flow' import type { ChainActionRequest, ChainWithActions, WalletBalances } from '../../lib/chains/types' import { QUOTE_EXPIRED } from '../../lib/chains/signing-flow' @@ -658,6 +661,8 @@ export function IntentChat({ 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 @@ -931,8 +936,14 @@ export function IntentChat({ chainSequence.delivered, chainSequence.error ) + // 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, @@ -1780,7 +1791,7 @@ export function IntentChat({ if (step.kind === 'refuse') { chainAction.refuse(step.message, text) } else if (step.kind === 'ask') { - setCashoutQuestion({ choices: step.choices, sequence: request }) + setCashoutQuestion({ choices: step.choices, sequence: { request, chain } }) recordLive('agent', CURRENCY_QUESTION) } else { runSequenceSwap( @@ -1920,14 +1931,32 @@ export function IntentChat({ }) } + /** 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, + ...asked.sequence.request, followOn: { kind: 'cashout', currency: currency as CashoutCurrencyCode }, }, message ?? said @@ -1970,6 +1999,14 @@ export function IntentChat({ 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 @@ -1980,15 +2017,7 @@ export function IntentChat({ 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 { - const reply = unsupportedCurrencyReply(named, offered) - setCashoutQuestion({ ...cashoutQuestion, retry: { said: text, reply } }) - recordLive('agent', reply) - } + answerNamedCurrency('currency' in answer ? answer.currency : answer.unsupported, text) return } } @@ -2201,9 +2230,18 @@ export function IntentChat({ // 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') { - // A swap must not start under whatever was said while this was being read. - if (writtenOut.kind === 'sequence' && asked.turn !== latest.current.turn) return - startCashout(writtenOut, turns[0]?.content ?? text) + const shown = turns[0]?.content ?? text + if (writtenOut.kind === 'sequence') { + // A swap must not start under whatever was said while this was being read. + if (asked.turn !== latest.current.turn || asked.chain !== latest.current.chain) return + // What gets signed comes from the person's own words, never from the model's. + const said = turns.filter((turn) => turn.role === 'user').map((turn) => turn.content) + if (!sequenceWordsMatch(writtenOut, said)) { + startCashout({ kind: 'token', token: writtenOut.token }, shown) + return + } + } + startCashout(writtenOut, shown) return } diff --git a/apps/dapp/hooks/use-chain-sequence.ts b/apps/dapp/hooks/use-chain-sequence.ts index 053b8fde..94ee006d 100644 --- a/apps/dapp/hooks/use-chain-sequence.ts +++ b/apps/dapp/hooks/use-chain-sequence.ts @@ -5,13 +5,20 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ChainSequenceRequest } from '../lib/chains/sequence' import { beforeReadCounts, + keepsBefore, measureDelivered, moveOnCard, outputToken, + quoteStep, + readBeforeSwap, + readCashoutNote, readHolding, readyAction, stageOnForm, + swapLine, unmeasuredReply, + type Before, + type BeforeStatus, type SequenceProgress, type SequenceStage, } from '../lib/chains/sequence-flow' @@ -28,12 +35,6 @@ interface Run extends SequenceProgress { account?: string } -/** What the output token's balance was before the swap. Anything but `known` strands at settled. */ -type Before = - | { status: 'unknown' } - | { status: 'reading' } - | { status: 'known'; amount: string; token: ChainToken; account: string } - const UNKNOWN: Before = { status: 'unknown' } export interface ChainSequence { @@ -45,6 +46,10 @@ export interface ChainSequence { settled: boolean /** The wallet step 1 was built for, once it has settled. */ account: 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 @@ -64,6 +69,8 @@ export function useChainSequence( 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) @@ -72,6 +79,8 @@ export function useChainSequence( // 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. @@ -80,28 +89,70 @@ export function useChainSequence( const request = run?.request const stage = run?.stage - // Before any signature, each time a card is put on offer: a wallet declined comes back here too. + 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 || phase !== 'review') return - if (prepared === undefined || builtFor === undefined) return - beforeRead.current += 1 - const mine = beforeRead.current - const token = outputToken(slug, prepared.receive?.symbol, request.swap.tokenOut) - if (token === undefined) { - before.current = UNKNOWN + if (request === undefined) return + const asked = request.swap.tokenOut + if (phase === 'building') { + readBefore(address, outputToken(slug, asked, asked)) return } - const account = builtFor - const family = prepared.signing.family - before.current = { status: 'reading' } - void readHolding(slug, account, token).then((amount) => { - if (mine !== beforeRead.current) return - before.current = - amount !== undefined && beforeReadCounts(phaseNow.current, family) - ? { status: 'known', amount, token, account } - : UNKNOWN + 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, 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) }) - }, [request, phase, prepared, builtFor, slug]) + return () => { + replaced = true + } + }, [request, prepared, slug]) useEffect(() => { setRun((r) => { @@ -163,17 +214,20 @@ export function useChainSequence( }) }, [formOpen, locked, paidHash]) - const start = useCallback((next: ChainSequenceRequest) => { - beforeRead.current += 1 - before.current = UNKNOWN - setRun({ request: next, stage: 'swap', settled: false }) - }, []) + const start = useCallback( + (next: ChainSequenceRequest) => { + beforeRead.current += 1 + noteBefore(UNKNOWN) + setRun({ request: next, stage: 'swap', settled: false }) + }, + [noteBefore] + ) const clear = useCallback(() => { beforeRead.current += 1 - before.current = UNKNOWN + noteBefore(UNKNOWN) setRun(null) - }, []) + }, [noteBefore]) const runNext = useCallback(() => { if (run?.stage !== 'ready' || run.delivered === undefined || run.account === undefined) return @@ -204,6 +258,8 @@ export function useChainSequence( error: run?.error, settled: run?.settled ?? false, account: run?.account, + readStatus, + swapLine: swapLine(phase, readStatus, quoted), start, runNext, clear, diff --git a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts index d45a5c7e..f3e5f2fa 100644 --- a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -2,9 +2,13 @@ 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, @@ -13,18 +17,27 @@ import { endedNote, followOnNotYet, holdingOf, + keepsBefore, measureDelivered, moveOnCard, outputToken, + questionAnswerFrom, + questionChainRefusal, + quoteNote, + quoteStep, + readBeforeSwap, + readCashoutNote, readHolding, readyAction, sequenceOutcome, + sequenceWordsMatch, stageAfterMeasure, stageAfterSwap, stageOnCard, stageOnForm, stepLabels, stepMarks, + swapLine, unmeasuredReply, } from '../chains/sequence-flow' import { strandedReply, type ChainSequenceRequest } from '../chains/sequence' @@ -237,6 +250,7 @@ describe('outputToken', () => { 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() }) }) @@ -307,11 +321,16 @@ describe('readHolding', () => { }) describe('beforeReadCounts', () => { - it('trusts a read that lands while the card is still on offer', () => { + 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) @@ -319,7 +338,7 @@ describe('beforeReadCounts', () => { expect(beforeReadCounts('signing', 'near')).toBe(false) }) - it.each(['submitting', 'settled', 'pending', 'failed', 'idle', 'building'] as const)( + it.each(['submitting', 'settled', 'pending', 'failed', 'idle'] as const)( 'does not trust one that lands at %s', (phase) => { expect(beforeReadCounts(phase, 'solana')).toBe(false) @@ -596,3 +615,277 @@ describe('followOnNotYet', () => { expect(followOnNotYet(followOn)).toContain('Nothing was signed or sent.') }) }) + +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('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 SOLANA as its own word, as the parser does, so it is not SOL', () => { + expect(sequenceWordsMatch(asked, ['cash out 5 solana'])).toBe(false) + expect(sequenceWordsMatch({ amount: '5', token: 'SOLANA' }, ['cash out 5 solana'])).toBe(true) + expect(sequenceWordsMatch(asked, ['cash out 5 SOLfoo'])).toBe(false) + }) + + 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 index ea916454..ed8c46af 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -231,7 +231,7 @@ describe('strandedReply', () => { 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. Check your wallet before doing anything else.' + '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.' ) }) 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/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index c0a5ef29..0451050e 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -1,4 +1,5 @@ import type { ChainActionPhase } from '../../hooks/use-chain-action' +import { walletFetch } from '../api/network-fetch' import { NO_CASHOUTS, accountMismatch, @@ -7,6 +8,7 @@ import { 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 { @@ -18,12 +20,13 @@ import { } from './sequence' import { SOLANA_TOKENS } from './solana/tokens' import { SUI_TOKENS } from './sui/tokens' -import type { - ChainToken, - ChainWithActions, - SigningPayload, - TokenBalance, - WalletBalances, +import { + tokenBySymbol, + type ChainToken, + type ChainWithActions, + type SigningPayload, + type TokenBalance, + type WalletBalances, } from './types' /** @@ -34,6 +37,7 @@ export type SequenceStage = 'swap' | 'measuring' | 'ready' | 'next' | 'stranded' 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 @@ -45,6 +49,17 @@ export const CASHOUT_UNDER_WAY = 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.' @@ -152,7 +167,8 @@ export function outputToken( ): ChainToken | undefined { if (receiveSymbol === undefined || asked === undefined) return undefined if (receiveSymbol.toUpperCase() !== asked.toUpperCase()) return undefined - return TOKEN_TABLES[chain as ChainWithActions]?.find((t) => t.symbol === receiveSymbol) + const table = TOKEN_TABLES[chain as ChainWithActions] + return table === undefined ? undefined : tokenBySymbol(table, receiveSymbol) } /** @@ -196,17 +212,45 @@ export async function readHolding( } } +/** 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 was asked at review, - * so it holds while the card 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. + * 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'] + family: SigningPayload['family'] | undefined ): boolean { - if (phase === 'review') return true - return phase === 'signing' && !walletSends(family) + 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 { @@ -248,6 +292,68 @@ export async function measureDelivered(args: { } } +/** What the swap's card says about what follows it, in place of promising what won't happen. */ +export function swapLine( + phase: ChainActionPhase, + status: BeforeStatus, + quoted: string | undefined +): string { + if (status === 'unknown' && (phase === 'review' || phase === 'signing')) return BALANCE_UNREAD + return quoted ?? SWAP_NEXT_STEP +} + +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, and whether that is enough to cash out. */ export function deliveredLine(delivered: string): string { return cashoutAmountFrom(delivered) === undefined @@ -372,6 +478,64 @@ export function currencyStep( : { kind: 'ask', choices } } +const NOT_THE_AMOUNT = + 'usd|usdc|dollars?|bucks?|naira|ngn|kes|ksh|ugx|tzs|shillings?|eur|euros?|gbp|pounds?' + +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 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, so SOLANA is not SOL: the parser reads it as its own word. + * 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 token = instruction.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const wordEnd = String.raw`(?![A-Za-z0-9+]|\.[A-Za-z0-9])` + const glued = new RegExp(String.raw`(\d)(${token})${wordEnd}`, 'gi') + const word = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])${token}${wordEnd}`, 'i') + const number = new RegExp( + String.raw`(?:^|[^\w.,$₦£€])(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)(?!\s*(?:${NOT_THE_AMOUNT})(?![\w+]))`, + 'gi' + ) + const wanted = canonicalNumber(instruction.amount) + const spaced = turns.map((turn) => turn.replace(glued, '$1 $2').replace(/([$₦£€])\s+/g, '$1')) + return ( + spaced.some((turn) => word.test(turn)) && + spaced.some((turn) => + [...turn.matchAll(number)].some((match) => canonicalNumber(match[1] as string) === wanted) + ) + ) +} + +/** + * 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.' +} + /** * The follow-ons this build can finish. Refused before step 1, like any follow-on that cannot run. * A cash-out is the only one yet: supply, stake and send arrive with `runNext` learning them. diff --git a/apps/dapp/lib/chains/sequence.ts b/apps/dapp/lib/chains/sequence.ts index 7b044a00..d16346c2 100644 --- a/apps/dapp/lib/chains/sequence.ts +++ b/apps/dapp/lib/chains/sequence.ts @@ -88,7 +88,7 @@ export function strandedReply( ): 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. Check your wallet before doing anything else.${paidOut}` + 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/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/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/types.ts b/apps/dapp/lib/weave/types.ts index 630fb36b..31f0db5c 100644 --- a/apps/dapp/lib/weave/types.ts +++ b/apps/dapp/lib/weave/types.ts @@ -11,6 +11,9 @@ export const CASHOUT_CHAINS: readonly CashoutChain[] = [ 'near', ] +/** Above this, an order is refused rather than offered. */ +export const MAX_COST_PERCENT = 5 + export function isCashoutChain(value: string): value is CashoutChain { return (CASHOUT_CHAINS as readonly string[]).includes(value) } From 501f601e5554df13f96f66b1101429626b43e901 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:04:52 +0100 Subject: [PATCH 14/27] fix(dapp): read a chain's name as its token in what a person types "Offramp 0.01477 solana" asked for a token the chain's table does not list, so it was refused. Off Stellar, solana, avalanche and monad now read as SOL, AVAX and MON, and a model's written-out cash-out compares through the same words, except where the person named the chain as a place, such as "5 USDT on solana". Stellar's answers do not change. --- apps/dapp/lib/__tests__/chain-action.test.ts | 47 ++++++++++++++++++- .../lib/__tests__/chain-sequence-flow.test.ts | 29 +++++++++++- .../dapp/lib/__tests__/chain-sequence.test.ts | 16 +++++++ .../lib/__tests__/weave-parse-cashout.test.ts | 35 ++++++++++++++ apps/dapp/lib/chains/parse-action.ts | 26 ++++++++-- apps/dapp/lib/chains/parse-sequence.ts | 6 +-- apps/dapp/lib/chains/sequence-flow.ts | 21 +++++++-- apps/dapp/lib/weave/parse-cashout.ts | 3 +- 8 files changed, 167 insertions(+), 16 deletions(-) diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index 201e7582..268bc797 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 { @@ -170,3 +170,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-sequence-flow.test.ts b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts index f3e5f2fa..9b261856 100644 --- a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -659,12 +659,37 @@ describe('sequenceWordsMatch', () => { expect(sequenceWordsMatch(asked, [said])).toBe(false) }) - it('reads SOLANA as its own word, as the parser does, so it is not SOL', () => { - expect(sequenceWordsMatch(asked, ['cash out 5 solana'])).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) diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index ed8c46af..4054fcee 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -17,6 +17,22 @@ 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' }], diff --git a/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts index 22adf517..e917f43c 100644 --- a/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts +++ b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts @@ -200,6 +200,41 @@ 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 sol'), chain)).toEqual({ kind: 'token', token: 'SOL' }) expect(routeCashout(read('cash out 5 usdc to brl'), chain)).toEqual({ diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index c8d874dc..d07157d8 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -22,6 +22,24 @@ const STAKE = new RegExp( const ACTION_VERB = /\b(swap|convert|exchange|trade|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i +/** 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. */ export function amountOf(text: string): string { const plain = text.replace(/,/g, '') @@ -41,8 +59,8 @@ export function parseChainAction(text: string): ChainActionRequest | null { return { kind: 'swap', amount: amountOf(swap[1] as string), - tokenIn: swap[2] as string, - tokenOut: swap[3] as string, + tokenIn: chainTokenWord(swap[2] as string), + tokenOut: chainTokenWord(swap[3] as string), venue: swap[4]?.trim(), } } @@ -51,7 +69,7 @@ export function parseChainAction(text: string): ChainActionRequest | null { return { kind: 'supply', amount: amountOf(supply[1] as string), - tokenIn: supply[2] as string, + tokenIn: chainTokenWord(supply[2] as string), venue: supply[3]?.trim(), } } @@ -60,7 +78,7 @@ export function parseChainAction(text: string): ChainActionRequest | null { return { kind: 'stake', amount: amountOf(stake[1] as string), - tokenIn: stake[2] as string, + tokenIn: chainTokenWord(stake[2] as string), venue: stake[3]?.trim(), } } diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index a68891d7..a75a14f6 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -1,5 +1,5 @@ import { parseCashout } from '../weave/parse-cashout' -import { amountOf } from './parse-action' +import { amountOf, chainTokenWord } from './parse-action' import type { ChainSequenceRequest, FollowOn } from './sequence' const AMOUNT = String.raw`\$?([\d,]*\.?\d+)` @@ -60,7 +60,7 @@ export function parseChainSequence( const followOn = followOnOf((match[4] as string).trim()) if (followOn === null) return null if ('refusal' in followOn) return followOn - const tokenOut = match[3] as string + const tokenOut = chainTokenWord(match[3] 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.`, @@ -70,7 +70,7 @@ export function parseChainSequence( swap: { kind: 'swap', amount: amountOf(match[1] as string), - tokenIn: match[2] as string, + tokenIn: chainTokenWord(match[2] as string), tokenOut, venue: undefined, }, diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index 0451050e..f74cdb1a 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -11,6 +11,7 @@ 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, @@ -492,17 +493,27 @@ function canonicalNumber(text: string): string { * 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 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, so SOLANA is not SOL: the parser reads it as its own word. - * Pass what the person typed, and none of the model's lines. + * 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 token = instruction.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const symbol = chainTokenWord(instruction.token) + const names = chainNamesOf(symbol) + const token = [symbol, ...names].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') const wordEnd = String.raw`(?![A-Za-z0-9+]|\.[A-Za-z0-9])` const glued = new RegExp(String.raw`(\d)(${token})${wordEnd}`, 'gi') - const word = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])${token}${wordEnd}`, 'i') + const word = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})${wordEnd}`, 'i') + const place = + names.length === 0 + ? undefined + : new RegExp( + String.raw`\b(?:on|in|from|to|into|at|via|using|through)\s+(?:my\s+|the\s+)?(?:${names.join('|')})\b|\b(?:${names.join('|')})\s+(?:wallet|account|address|chain|network|blockchain)\b`, + 'gi' + ) const number = new RegExp( String.raw`(?:^|[^\w.,$₦£€])(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)(?!\s*(?:${NOT_THE_AMOUNT})(?![\w+]))`, 'gi' @@ -510,7 +521,7 @@ export function sequenceWordsMatch( const wanted = canonicalNumber(instruction.amount) const spaced = turns.map((turn) => turn.replace(glued, '$1 $2').replace(/([$₦£€])\s+/g, '$1')) return ( - spaced.some((turn) => word.test(turn)) && + spaced.some((turn) => word.test(place === undefined ? turn : turn.replace(place, ' '))) && spaced.some((turn) => [...turn.matchAll(number)].some((match) => canonicalNumber(match[1] as string) === wanted) ) diff --git a/apps/dapp/lib/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index 00daaa3d..802df179 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -1,3 +1,4 @@ +import { chainTokenWord } from '../chains/parse-action' import { SEQUENCE_CASHOUT_CHAINS } from '../chains/sequence' import { isCashoutChain } from './types' @@ -264,7 +265,7 @@ export function routeCashout( ) { return { kind: 'sequence', - token: read.unsupportedToken, + token: chainTokenWord(read.unsupportedToken), amount: read.amount, ...(read.currency !== undefined ? { currency: read.currency } : {}), } From 43b9f552e96a43f29ed94675e26de01832878955 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:34:22 +0100 Subject: [PATCH 15/27] feat(dapp): swap then deposit, stake or send on every chain A send reuses each chain's deposit builder and signs its recipient, token and amount with an HMAC proof, so a browser can't swap them. Without AUTH_SECRET no send venue is offered. Step 2 of a deposit, stake or send runs on the chainAction card from a press at ready, and a signed step 2 can't return to ready. A new follow-on route refuses what can't run before step 1. --- .../app/api/chain/[chain]/follow-on/route.ts | 64 + .../app/api/chain/[chain]/prepare/route.ts | 6 +- .../intents/chain-action-confirm.tsx | 8 +- .../intents/chain-sequence-steps.tsx | 36 +- apps/dapp/components/intents/intent-chat.tsx | 38 +- apps/dapp/hooks/use-chain-action.ts | 6 +- apps/dapp/hooks/use-chain-sequence.ts | 49 +- .../__tests__/agents-chain-compete.test.ts | 17 + apps/dapp/lib/__tests__/chain-send.test.ts | 1387 +++++++++++++++++ .../lib/__tests__/chain-sequence-flow.test.ts | 353 ++++- apps/dapp/lib/__tests__/evm-module.test.ts | 7 +- apps/dapp/lib/__tests__/near-venues.test.ts | 4 +- .../__tests__/solana-sui-race-quote.test.ts | 5 +- apps/dapp/lib/__tests__/solana-venues.test.ts | 1 + apps/dapp/lib/__tests__/sui-module.test.ts | 8 +- apps/dapp/lib/chains/evm/module.ts | 5 +- apps/dapp/lib/chains/explain.ts | 1 + apps/dapp/lib/chains/near/earn.ts | 2 + apps/dapp/lib/chains/near/module.ts | 19 +- apps/dapp/lib/chains/near/safety.ts | 14 +- apps/dapp/lib/chains/near/stake.ts | 13 + apps/dapp/lib/chains/oneclick-origin.ts | 5 +- apps/dapp/lib/chains/parse-action.ts | 19 + apps/dapp/lib/chains/prepare.ts | 49 +- apps/dapp/lib/chains/recipient.ts | 116 ++ apps/dapp/lib/chains/send.ts | 379 +++++ apps/dapp/lib/chains/sequence-flow.ts | 252 ++- apps/dapp/lib/chains/sequence.ts | 11 +- apps/dapp/lib/chains/solana/jupiter-lend.ts | 2 + apps/dapp/lib/chains/solana/kamino.ts | 2 + apps/dapp/lib/chains/solana/lulo.ts | 2 + apps/dapp/lib/chains/solana/module.ts | 4 +- apps/dapp/lib/chains/sui/module.ts | 4 +- apps/dapp/lib/chains/sui/navi.ts | 2 + apps/dapp/lib/chains/sui/springsui.ts | 2 + apps/dapp/lib/chains/types.ts | 31 +- apps/dapp/lib/chat/live-session.ts | 8 + apps/dapp/lib/wallet/address.ts | 4 +- apps/dapp/lib/weave/deposit/solana.ts | 2 +- 39 files changed, 2837 insertions(+), 100 deletions(-) create mode 100644 apps/dapp/app/api/chain/[chain]/follow-on/route.ts create mode 100644 apps/dapp/lib/__tests__/chain-send.test.ts create mode 100644 apps/dapp/lib/chains/recipient.ts create mode 100644 apps/dapp/lib/chains/send.ts 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/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 7905ea52..2bd8f3dd 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -15,7 +15,7 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele {phase === 'building' ? (

) : null} @@ -48,9 +48,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 index bbff2a43..47b0be40 100644 --- a/apps/dapp/components/intents/chain-sequence-steps.tsx +++ b/apps/dapp/components/intents/chain-sequence-steps.tsx @@ -6,7 +6,13 @@ import { useEffect, useRef } from 'react' import type { ChainSequence } from '../../hooks/use-chain-sequence' import { cashoutAmountFrom } from '../../lib/chains/sequence' -import { deliveredLine, stepLabels, stepMarks, type StepMark } from '../../lib/chains/sequence-flow' +import { + deliveredLine, + secondStepLabel, + stepLabels, + stepMarks, + type StepMark, +} from '../../lib/chains/sequence-flow' function Mark({ mark, number }: { mark: StepMark; number: number }): JSX.Element { return ( @@ -46,7 +52,11 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele 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 ? cashoutAmountFrom(delivered) : undefined + stage === 'ready' && delivered !== undefined && sequence.followOn.kind === 'cashout' + ? cashoutAmountFrom(delivered) + : undefined + const ownCard = + stage === 'ready' && delivered !== undefined ? secondStepLabel(sequence, delivered) : undefined return (
@@ -59,13 +69,11 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele
  • {second} @@ -85,7 +93,7 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele {stage === 'ready' && delivered !== undefined ? (

    - {deliveredLine(delivered)} + {deliveredLine(delivered, sequence)}

    ) : null} @@ -97,6 +105,14 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele
  • ) : null} + {ownCard !== undefined ? ( +
    + +
    + ) : null} + {stage === 'next' ? (

    The form below is yours to finish or leave. Leaving it pays nothing out. diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index ce0c6fac..1841a97d 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -79,7 +79,7 @@ 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 { looksLikeChainAction, parseChainAction, racesFirst } from '../../lib/chains/parse-action' import { parseChainSequence } from '../../lib/chains/parse-sequence' import { followOnRefusal, type ChainSequenceRequest } from '../../lib/chains/sequence' import { @@ -87,10 +87,10 @@ import { cashoutSequence, currencyStep, endedNote, - followOnNotYet, questionAnswerFrom, questionChainRefusal, sequenceOutcome, + readFollowOnRefusal, sequenceWordsMatch, } from '../../lib/chains/sequence-flow' import type { ChainActionRequest, ChainWithActions, WalletBalances } from '../../lib/chains/types' @@ -934,7 +934,8 @@ export function IntentChat({ const sequenceLine = sequenceOutcome( chainSequence.stage, chainSequence.delivered, - chainSequence.error + chainSequence.error, + chainSequence.sequence ?? undefined ) // Leaving the form brings `ready` back, and what arrived was said the first time. const deliveredRecordedFor = useRef(null) @@ -1766,15 +1767,30 @@ export function IntentChat({ slug as ChainWithActions, descriptor.name, followOn, - capabilities.map((c) => ({ kinds: [c.kind] })) - ) ?? - followOnNotYet(followOn) + capabilities.map((c) => ({ kinds: [c.kind] })), + request.swap.tokenOut + ) if (refusal !== undefined) { chainAction.refuse(refusal, text) return } if (followOn.kind !== 'cashout') { - runSequenceSwap(request, text) + 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. @@ -2057,7 +2073,13 @@ export function IntentChat({ // race is the one thing that lifts the "already signed" latch. chainAction.release() chainAction.reset() - setChainRace({ request: action, text }) + 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) + } return } if (looksLikeChainAction(text)) { diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index 6d9a3e6f..e234e78f 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react' -import type { ChainActionRequest, PreparedAction } from '../lib/chains/types' +import type { ChainActionKind, ChainActionRequest, PreparedAction } from '../lib/chains/types' import { accountMismatch, afterSigning, @@ -37,6 +37,8 @@ export type ChainActionPhase = export interface ChainActionState { phase: ChainActionPhase asked?: string + /** What is being built, before there is a card to read it from. */ + building?: ChainActionKind prepared?: PreparedAction hash?: string explorerUrl?: string @@ -112,7 +114,7 @@ export function useChainAction(): ChainAction { const mine = generation.current async function run(): Promise { - 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 index 94ee006d..0e816453 100644 --- a/apps/dapp/hooks/use-chain-sequence.ts +++ b/apps/dapp/hooks/use-chain-sequence.ts @@ -8,13 +8,17 @@ import { keepsBefore, measureDelivered, moveOnCard, + moveOnSecond, + nextRequest, outputToken, quoteStep, readBeforeSwap, readCashoutNote, readHolding, readyAction, + secondStepPress, stageOnForm, + stepLabels, swapLine, unmeasuredReply, type Before, @@ -61,6 +65,10 @@ export interface ChainSequence { * 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, @@ -88,6 +96,8 @@ export function useChainSequence( 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 @@ -120,7 +130,7 @@ export function useChainSequence( // 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) return + if (request === undefined || pastSwap) return const asked = request.swap.tokenOut if (phase === 'building') { readBefore(address, outputToken(slug, asked, asked)) @@ -130,7 +140,7 @@ export function useChainSequence( const token = outputToken(slug, prepared.receive?.symbol, asked) if (token !== undefined && keepsBefore(before.current, builtFor, token.id)) return readBefore(builtFor, token) - }, [request, phase, prepared, builtFor, address, slug, readBefore]) + }, [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. @@ -157,6 +167,12 @@ export function useChainSequence( useEffect(() => { setRun((r) => { if (r === null) return r + if (r.stage === 'following') { + const second = moveOnSecond(r, phase, signed, r.request, r.delivered) + 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 @@ -232,9 +248,28 @@ export function useChainSequence( const runNext = useCallback(() => { if (run?.stage !== 'ready' || run.delivered === undefined || run.account === undefined) return const { followOn } = run.request - // Step 2 by kind. A cash-out is the only one so far: `followOnNotYet` turns the rest away - // before step 1, and each goes here when it is built. - if (followOn.kind !== 'cashout') return + if (followOn.kind !== 'cashout') { + if (!run.settled) return + const asked = run.request.swap.tokenOut + const token = outputToken(slug, asked, asked) + const press = secondStepPress( + token === undefined ? undefined : nextRequest(run.request, run.delivered, 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 })) @@ -249,7 +284,7 @@ export function useChainSequence( amount: action.amount, ...(followOn.currency !== undefined ? { currency: followOn.currency } : {}), }) - }, [run, cashout]) + }, [run, cashout, chainAction, slug]) return { sequence: run?.request ?? null, @@ -259,7 +294,7 @@ export function useChainSequence( settled: run?.settled ?? false, account: run?.account, readStatus, - swapLine: swapLine(phase, readStatus, quoted), + 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__/chain-send.test.ts b/apps/dapp/lib/__tests__/chain-send.test.ts new file mode 100644 index 00000000..89cb48bc --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-send.test.ts @@ -0,0 +1,1387 @@ +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 { 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: true }), + ...over, + } +} + +interface Sim { + rpc: string[] + accountOwner: string | undefined + 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 }, + }, + }, + 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, 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 signed or sent\.$/) + 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) + }) + + 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('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('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/) + }) + + 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-flow.test.ts b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts index 9b261856..9ba94694 100644 --- a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -15,11 +15,12 @@ import { currencyStep, deliveredLine, endedNote, - followOnNotYet, holdingOf, keepsBefore, measureDelivered, moveOnCard, + moveOnSecond, + nextRequest, outputToken, questionAnswerFrom, questionChainRefusal, @@ -27,8 +28,11 @@ import { quoteStep, readBeforeSwap, readCashoutNote, + readFollowOnRefusal, readHolding, readyAction, + secondStepLabel, + secondStepPress, sequenceOutcome, sequenceWordsMatch, stageAfterMeasure, @@ -191,6 +195,338 @@ describe('moveOnCard', () => { }) }) +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')).toBe(OPENING) + expect(moveOnSecond(OPENING, phase, false, SEND, '5')).toBe(OPENING) + } + }) + + it('opens when its own build starts, and holds while the card is live', () => { + expect(moveOnSecond(OPENING, 'building', false, SEND, '5')).toEqual(OPEN) + for (const phase of ['building', 'review', 'signing', 'submitting'] as const) { + expect(moveOnSecond(OPEN, phase, true, SEND, '5')).toBe(OPEN) + expect(moveOnSecond(OPEN, phase, false, SEND, '5')).toBe(OPEN) + } + }) + + it('finishes on settled, keeping step 1 flagged settled and measuring nothing', () => { + const moved = moveOnSecond(OPEN, 'settled', true, SEND, '5') + 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') + 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').stage).toBe('unsettled') + expect(moveOnSecond(OPEN, 'idle', true, SEND, '5').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').stage).toBe('ready') + expect(moveOnSecond(OPEN, 'idle', false, SEND, '5').stage).toBe('ready') + }) + + it('never brings the button back once step 2 has ended, whatever the card does', () => { + const ended = [ + moveOnSecond(OPEN, 'settled', true, SEND, '5'), + moveOnSecond(OPEN, 'pending', true, SEND, '5'), + moveOnSecond(OPEN, 'failed', true, SEND, '5'), + ] + 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')).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') + 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('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 } @@ -601,21 +937,6 @@ describe('currencyStep', () => { }) }) -describe('followOnNotYet', () => { - it('lets a cash-out through', () => { - expect(followOnNotYet({ kind: 'cashout' })).toBeUndefined() - }) - - it.each([ - [{ kind: 'supply' as const }], - [{ kind: 'stake' as const }], - [{ kind: 'send' as const, recipient: ALICE }], - ])('says not yet for %j, with nothing signed', (followOn) => { - expect(followOnNotYet(followOn)).toMatch(/not yet/i) - expect(followOnNotYet(followOn)).toContain('Nothing was signed or sent.') - }) -}) - describe('sequenceWordsMatch', () => { const asked = { amount: '5', token: 'SOL' } diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index aa168593..089ab582 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', ]) }) 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__/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.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..a0c0637e 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,7 +299,12 @@ 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', () => { diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts index 002fd162..1f843f16 100644 --- a/apps/dapp/lib/chains/evm/module.ts +++ b/apps/dapp/lib/chains/evm/module.ts @@ -1,5 +1,6 @@ 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' @@ -8,7 +9,7 @@ export function buildEvmVenues( chain: EvmChain, env: Record = process.env ): ChainVenue[] { - return canIssueOneClick(env) ? [oneClickOriginVenue(chain)] : [] + return canIssueOneClick(env) ? [oneClickOriginVenue(chain), createSendVenue(chain)] : [] } /** @@ -29,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 () => ({ diff --git a/apps/dapp/lib/chains/explain.ts b/apps/dapp/lib/chains/explain.ts index a991368b..89a523eb 100644 --- a/apps/dapp/lib/chains/explain.ts +++ b/apps/dapp/lib/chains/explain.ts @@ -4,6 +4,7 @@ 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`, } export interface ChainCapability { 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 484590fa..672c3dc3 100644 --- a/apps/dapp/lib/chains/near/module.ts +++ b/apps/dapp/lib/chains/near/module.ts @@ -1,4 +1,6 @@ -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' @@ -6,10 +8,22 @@ import { confirmStake, metapoolVenue, METAPOOL_VENUE_ID, rnearVenue } from './st 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, @@ -19,6 +33,7 @@ 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, 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 d07157d8..0e6a4eb9 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -17,6 +17,11 @@ 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 = @@ -82,9 +87,23 @@ export function parseChainAction(text: string): ChainActionRequest | null { venue: stake[3]?.trim(), } } + const send = SEND.exec(text.trim()) + if (send !== null) { + return { + kind: 'send', + amount: amountOf(send[1] as string), + tokenIn: chainTokenWord(send[2] as string), + 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/prepare.ts b/apps/dapp/lib/chains/prepare.ts index 13f10bf9..df39df35 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -2,6 +2,7 @@ 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 { ChainActionRefused, @@ -11,6 +12,7 @@ import { type ChainToken, type ChainWithActions, type ChainVenue, + type FollowOnStep, type PreparedAction, } from './types' @@ -116,18 +118,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 @@ -143,6 +187,7 @@ 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) 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..ab0f0b94 --- /dev/null +++ b/apps/dapp/lib/chains/send.ts @@ -0,0 +1,379 @@ +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}` +const NOT_ISSUED = `This transfer's details are not what this app issued. ${NOTHING_MOVED}` +const NOT_THE_TRANSFER = `This is not the transfer that was asked for, so it was stopped. ${NOTHING_MOVED}` + +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 +} + +/** 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: assertDepositIsNotTokenAccount, + 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() + return { + ok: false, + error: `This transfer did not go through, so your ${expectation.token?.symbol ?? 'funds'} did not move. You can try again.`, + } + } catch (e) { + // A refusal from the builder's own check comes before it sends. Anything else may be after. + return e instanceof ChainActionRefused && !sends + ? { ok: false, error: NOT_THE_TRANSFER } + : 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 index f74cdb1a..3183cb10 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -15,7 +15,9 @@ import { chainNamesOf, chainTokenWord } from './parse-action' import { cashoutAmountFrom, deliveredAmount, + fromBase, strandedReply, + toBase, type ChainSequenceRequest, type FollowOn, } from './sequence' @@ -23,6 +25,7 @@ import { SOLANA_TOKENS } from './solana/tokens' import { SUI_TOKENS } from './sui/tokens' import { tokenBySymbol, + type ChainActionRequest, type ChainToken, type ChainWithActions, type SigningPayload, @@ -33,8 +36,22 @@ import { /** * 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' | 'stranded' | 'done' +export type SequenceStage = + | 'swap' + | 'measuring' + | 'ready' + | 'next' + | 'following' + | 'followed' + | 'unsettled' + | 'stranded' + | 'done' export const MEASURE_INTERVAL_MS = 3_000 export const MEASURE_ATTEMPTS = 20 @@ -46,6 +63,12 @@ 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." @@ -64,6 +87,18 @@ 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, @@ -99,6 +134,8 @@ export interface SequenceProgress { /** 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 } /** @@ -121,11 +158,48 @@ export function moveOnCard( ? { stage, settled: true } : { stage: 'stranded', settled: true, error: unmeasuredReply(request) } } - if (stage === 'stranded') return { stage, settled: false, error: UNCONFIRMED_SWAP } - if (stage === 'done') return { stage, settled: false, error: CASHOUT_NOT_STARTED } + 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. + */ +export function moveOnSecond( + progress: SequenceProgress, + phase: ChainActionPhase, + signed: boolean, + request: ChainSequenceRequest, + delivered: string | undefined +): SequenceProgress { + if (progress.stage !== 'following') return progress + if (progress.opened !== true) { + return phase === 'building' ? { ...progress, opened: true } : progress + } + if (phase === 'settled') return { stage: 'followed', settled: true } + const ended = phase === 'pending' || phase === 'failed' || phase === 'idle' + if (!ended) return progress + if (phase !== 'pending' && !signed) return { stage: 'ready', settled: true } + 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. @@ -293,14 +367,24 @@ export async function measureDelivered(args: { } } -/** What the swap's card says about what follows it, in place of promising what won't happen. */ +/** + * 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 + quoted: string | undefined, + request?: ChainSequenceRequest ): string { - if (status === 'unknown' && (phase === 'review' || phase === 'signing')) return BALANCE_UNREAD - return quoted ?? SWAP_NEXT_STEP + 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 = @@ -355,8 +439,11 @@ export async function readCashoutNote( } } -/** What reached the wallet, and whether that is enough to cash out. */ -export function deliveredLine(delivered: string): string { +/** 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.` @@ -388,24 +475,30 @@ export function readyAction( export function sequenceOutcome( stage: SequenceStage | undefined, delivered: string | undefined, - error: string | undefined + error: string | undefined, + request?: ChainSequenceRequest ): string | undefined { - if (stage === 'ready' && delivered !== undefined) return deliveredLine(delivered) - return stage === 'stranded' || stage === 'done' ? error : undefined + if (stage === 'ready' && delivered !== undefined) return deliveredLine(delivered, request) + return stage === 'stranded' || stage === 'done' || stage === 'unsettled' ? error : undefined } function outputSymbol(request: ChainSequenceRequest): string { return (request.swap.tokenOut ?? '').toUpperCase() } -/** Said when a swap that settled is left before step 2 ran, or nothing where there is no news. */ +/** + * 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') return undefined + if (stage !== 'measuring' && stage !== 'ready' && stage !== 'next' && stage !== 'following') { + return undefined + } return strandedReply(outputSymbol(request), delivered, request.followOn) } @@ -417,13 +510,89 @@ export function unmeasuredReply(request: ChainSequenceRequest): string { export function stepLabels(request: ChainSequenceRequest): [string, string] { const { amount, tokenIn } = request.swap const first = `Swap ${amount} ${tokenIn.toUpperCase()} 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 - return [ - first, - followOn.kind === 'cashout' && followOn.currency !== undefined - ? `Cash out to ${followOn.currency}` - : 'Cash out', - ] + 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 } : {}), + } +} + +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' @@ -431,7 +600,8 @@ 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') return ['done', 'stopped'] + if (stage === 'stranded' || stage === 'unsettled') return ['done', 'stopped'] + if (stage === 'followed') return ['done', 'done'] return stage === 'measuring' ? ['done', 'waiting'] : ['done', 'now'] } @@ -547,12 +717,40 @@ export function questionChainRefusal(asked: string, now: string): string | undef : '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.' + /** - * The follow-ons this build can finish. Refused before step 1, like any follow-on that cannot run. - * A cash-out is the only one yet: supply, stake and send arrive with `runNext` learning them. + * 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 function followOnNotYet(followOn: FollowOn): string | undefined { - return followOn.kind === 'cashout' - ? undefined - : 'A swap followed by a deposit, a stake or a send is not yet available here. Nothing was signed or sent.' +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 index d16346c2..87c9191a 100644 --- a/apps/dapp/lib/chains/sequence.ts +++ b/apps/dapp/lib/chains/sequence.ts @@ -1,4 +1,5 @@ import type { CashoutCurrencyCode } from '../weave/parse-cashout' +import { sendRefusal } from './recipient' import type { ChainActionRequest, ChainVenue, ChainWithActions } from './types' export type FollowOn = @@ -57,12 +58,16 @@ export function cashoutAmountFrom(delivered: string): string | undefined { 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. */ +/** + * 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[] + venues: readonly Pick[], + tokenOut?: string ): string | undefined { if (followOn.kind === 'cashout') { if (chain === 'near') { @@ -73,7 +78,7 @@ export function followOnRefusal( } return undefined } - if (followOn.kind === 'send') return undefined + if (followOn.kind === 'send') return sendRefusal(chain, tokenOut, followOn.recipient) if (!venues.some((v) => v.kinds.includes(followOn.kind))) { return `Nothing on ${chainName} can ${followOn.kind} yet. ${NOTHING_MOVED}` } 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 a9526489..22e5fc0a 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -1,4 +1,5 @@ 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' @@ -24,6 +25,7 @@ export function buildSolanaVenues( createJupiterLendVenue({ env }), createKaminoVenue(), ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []), + ...(canIssueOneClick(env) ? [createSendVenue('solana')] : []), ] } @@ -31,7 +33,7 @@ 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/module.ts b/apps/dapp/lib/chains/sui/module.ts index 49beb955..67111346 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,4 +1,5 @@ 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' @@ -16,6 +17,7 @@ export function buildSuiVenues( ...(canIssueOneClick(env) ? [oneClickOriginVenue('sui')] : []), naviVenue, springsuiVenue, + ...(canIssueOneClick(env) ? [createSendVenue('sui')] : []), ] } @@ -23,7 +25,7 @@ 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 75263cec..df05de48 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -8,7 +8,7 @@ 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 { @@ -19,6 +19,8 @@ export interface ChainActionRequest { 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,6 +138,33 @@ 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.` + ) + } } /** diff --git a/apps/dapp/lib/chat/live-session.ts b/apps/dapp/lib/chat/live-session.ts index 36841630..2e51b789 100644 --- a/apps/dapp/lib/chat/live-session.ts +++ b/apps/dapp/lib/chat/live-session.ts @@ -1,3 +1,4 @@ +import { SEND_VENUE_NAME } from '../chains/recipient' import { accountMismatch } from '../weave/cashout-view' /** @@ -169,6 +170,13 @@ export function chainOutcomeSentence(outcome: { }): 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.` 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/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index 0ddf5480..d42f071d 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -290,7 +290,7 @@ 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 ): Promise { From 16327e737b65b1e66981e57d8bc84aaa832879da Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:55:43 +0100 Subject: [PATCH 16/27] fix(dapp): close the race once a sequence's swap settles, and check what a send holds Review on a settled swap's race could build that swap again over step 2's card, and its settling would tick step 2. A settled sequence now closes the race, and only a card of the follow-on's kind can finish step 2. Also: no claim that nothing was signed after a swap or wallet signature, a known shortfall refused before a send, decimal commas no longer read as ten times the amount, Solana programs refused as recipients, and the network's fee and balance sentences kept. --- .../intents/chain-action-confirm.tsx | 13 +- apps/dapp/components/intents/intent-chat.tsx | 22 +- apps/dapp/hooks/use-chain-sequence.ts | 4 +- .../lib/__tests__/agents-chain-race.test.ts | 75 ++++++ apps/dapp/lib/__tests__/chain-action.test.ts | 25 +- apps/dapp/lib/__tests__/chain-send.test.ts | 222 +++++++++++++++++- .../lib/__tests__/chain-sequence-flow.test.ts | 60 +++-- .../dapp/lib/__tests__/chain-sequence.test.ts | 13 + apps/dapp/lib/agents/chain-race.ts | 42 +++- apps/dapp/lib/chains/parse-action.ts | 10 +- apps/dapp/lib/chains/parse-sequence.ts | 3 +- apps/dapp/lib/chains/prepare.ts | 73 ++++-- apps/dapp/lib/chains/send.ts | 23 +- apps/dapp/lib/chains/sequence-flow.ts | 29 ++- apps/dapp/lib/weave/deposit/solana.ts | 11 +- 15 files changed, 553 insertions(+), 72 deletions(-) diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 2bd8f3dd..801a6f66 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -4,10 +4,19 @@ 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, +}: { + action: ChainAction + /** This card is step 2, after a swap that was signed and settled. */ + sequenceSettled?: boolean +}): JSX.Element { + const { phase, prepared, hash, explorerUrl, earlier } = action + const error = stepErrorMessage(action.error, sequenceSettled) const busy = phase === 'signing' || phase === 'submitting' return ( diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 1841a97d..cb90aa40 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -40,6 +40,8 @@ import { ACKNOWLEDGED } from '../../lib/chat/clarify-flow' import type { ClarifyTurn } from '../../lib/clarify/clarify' import { chainReviewClosed, + raceClosed, + stepErrorMessage, chainSigning, isChainRoute, needsDirectRoute, @@ -638,6 +640,8 @@ export function IntentChat({ }) 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') { @@ -917,7 +921,7 @@ export function IntentChat({ const chainOutcome = chainOutcomeSentence({ phase: chainAction.phase, venueName: chainAction.prepared?.venueName, - error: chainAction.error, + error: stepErrorMessage(chainAction.error, chainSequence.settled), }) useEffect(() => { if (chainOutcome === undefined || message === null) return @@ -2423,7 +2427,7 @@ export function IntentChat({ 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, @@ -3098,10 +3102,13 @@ export function IntentChat({ 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 @@ -3280,14 +3287,17 @@ export function IntentChat({ {restored === null ? : null} - {showDirectNote(directFallback, chainAction.phase) ? ( + {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} diff --git a/apps/dapp/hooks/use-chain-sequence.ts b/apps/dapp/hooks/use-chain-sequence.ts index 0e816453..432c0175 100644 --- a/apps/dapp/hooks/use-chain-sequence.ts +++ b/apps/dapp/hooks/use-chain-sequence.ts @@ -168,7 +168,7 @@ export function useChainSequence( setRun((r) => { if (r === null) return r if (r.stage === 'following') { - const second = moveOnSecond(r, phase, signed, r.request, r.delivered) + 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 } @@ -185,7 +185,7 @@ export function useChainSequence( ...(settled && known.status === 'known' ? { account: known.account } : {}), } }) - }, [phase, signed]) + }, [phase, signed, prepared?.kind]) // One read at a time, ending on unmount, on `clear` and at the limit. const measuring = stage === 'measuring' diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts index 98c64452..5263b315 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,73 @@ 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('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__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index 268bc797..b4e72ed5 100644 --- a/apps/dapp/lib/__tests__/chain-action.test.ts +++ b/apps/dapp/lib/__tests__/chain-action.test.ts @@ -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,30 @@ describe('parseChainAction', () => { }) }) + it.each(['1,5', '0,5', '1,50', '10,00', '1,0000', ',5', '1,,000', '1000,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'], + ['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() diff --git a/apps/dapp/lib/__tests__/chain-send.test.ts b/apps/dapp/lib/__tests__/chain-send.test.ts index 89cb48bc..b40e6a91 100644 --- a/apps/dapp/lib/__tests__/chain-send.test.ts +++ b/apps/dapp/lib/__tests__/chain-send.test.ts @@ -38,6 +38,7 @@ 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 { @@ -169,7 +170,7 @@ function moduleFor(chain: ChainWithActions, over: Partial = {}): Ch defaultVenue: { send: SEND_VENUE_ID }, priceUsd: async () => 1, submit: async () => ({ ok: false, error: 'unused' }), - balances: async () => ({ tokens: [], complete: true }), + balances: async () => ({ tokens: [], complete: false }), ...over, } } @@ -177,6 +178,7 @@ function moduleFor(chain: ChainWithActions, over: Partial = {}): Ch interface Sim { rpc: string[] accountOwner: string | undefined + accountExecutable: boolean send: () => unknown } @@ -192,7 +194,10 @@ function installFetch(): void { getAccountInfo: { result: { context: { slot: 1 }, - value: sim.accountOwner === undefined ? null : { owner: sim.accountOwner }, + value: + sim.accountOwner === undefined + ? null + : { owner: sim.accountOwner, executable: sim.accountExecutable }, }, }, getLatestBlockhash: { @@ -223,7 +228,12 @@ function coinObject(objectId: string, type = SUI_USDC.id, owner = SUI_ME) { } beforeEach(() => { - sim = { rpc: [], accountOwner: undefined, send: () => ({ result: 'sent' }) } + 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() @@ -579,7 +589,8 @@ describe('a send on Solana', () => { { family: 'solana', transaction: solanaBytes({ extra: true }) } as const, ]) { const said = refusal(() => venue.assertSafe(payload, SOL_ME, prepared.context)) - expect(said).toMatch(/ Nothing was signed or sent\.$/) + expect(said).toMatch(/ Nothing was sent\.$/) + expect(said).not.toMatch(/signed/) expect(said).not.toMatch(WORDS_FOR_A_DEPOSIT) } }) @@ -591,6 +602,44 @@ describe('a send on Solana', () => { 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)) @@ -679,6 +728,37 @@ describe('a browser that changes what it sends back, on Solana', () => { 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 = () => { @@ -749,6 +829,37 @@ describe('a send on Sui', () => { 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 }) @@ -1095,6 +1206,109 @@ describe('prepareChainAction on a send', () => { 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( diff --git a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts index 9ba94694..a42076b1 100644 --- a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -205,28 +205,28 @@ describe('step 2 on its own card', () => { 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')).toBe(OPENING) - expect(moveOnSecond(OPENING, phase, false, SEND, '5')).toBe(OPENING) + 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')).toEqual(OPEN) + 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')).toBe(OPEN) - expect(moveOnSecond(OPEN, phase, false, SEND, '5')).toBe(OPEN) + 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') + 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') + 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') @@ -235,20 +235,33 @@ describe('step 2 on its own card', () => { }) it('ends on a failure that can’t prove nothing moved', () => { - expect(moveOnSecond(OPEN, 'failed', true, SEND, '5').stage).toBe('unsettled') - expect(moveOnSecond(OPEN, 'idle', true, SEND, '5').stage).toBe('unsettled') + 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').stage).toBe('ready') - expect(moveOnSecond(OPEN, 'idle', false, SEND, '5').stage).toBe('ready') + 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'), - moveOnSecond(OPEN, 'pending', true, SEND, '5'), - moveOnSecond(OPEN, 'failed', true, SEND, '5'), + 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 [ @@ -262,7 +275,7 @@ describe('step 2 on its own card', () => { 'failed', ] as const) { for (const signed of [true, false]) { - expect(moveOnSecond(progress, phase, signed, SEND, '5')).toBe(progress) + expect(moveOnSecond(progress, phase, signed, SEND, '5', 'send')).toBe(progress) expect(moveOnCard(progress, phase, signed, true, SEND)).toBe(progress) } } @@ -283,7 +296,7 @@ describe('step 2 on its own card', () => { let progress: Parameters[0] = OPENING const seen: string[] = [] for (const [phase, signed] of phases) { - progress = moveOnSecond(progress, phase, signed, SEND, '5') + progress = moveOnSecond(progress, phase, signed, SEND, '5', 'send') seen.push(progress.stage) } expect(seen).not.toContain('ready') @@ -427,6 +440,21 @@ describe('words for what follows a swap', () => { 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') diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index 4054fcee..940956aa 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -85,6 +85,19 @@ describe('a token read beside a currency we do not pay', () => { }) 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']) { + 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', diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index 008e62e3..a7b904bb 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -95,9 +95,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 +238,26 @@ export function recommendationLine( return `Recommended: ${name}, ${why}. You pick who executes.` } +export const STEP_NOT_STARTED = + 'This step did not start. The swap stands, and what arrived stays in your wallet.' + +/** + * 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. + */ +export function stepErrorMessage( + message: string | undefined, + sequenceSettled: boolean +): string | undefined { + if (message === undefined || !sequenceSettled) return message + if (!/nothing was signed or sent/i.test(message)) return message + const rest = message + .replace(/,\s*so nothing was signed or sent\.?/gi, '.') + .replace(/\s*Nothing was signed or sent(?:, so try again in a moment)?\.?/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/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index 0e6a4eb9..53d01a7c 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -1,6 +1,10 @@ import type { ChainActionRequest } from './types' -const AMOUNT = String.raw`\$?([\d,]*\.?\d+)` +/** + * Commas only as thousands, 1,000 or 1,000.5. In "1,5" a comma is a decimal point, and dropping it + * would read ten times the amount, so that is no amount at all and the person is told what works. + */ +export const AMOUNT = String.raw`\$?((?:\d{1,3}(?:,\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*$` @@ -45,10 +49,10 @@ export function chainNamesOf(token: string): string[] { 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. */ +/** 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)/, '') } /** diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index a75a14f6..0e3c877e 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -1,8 +1,7 @@ import { parseCashout } from '../weave/parse-cashout' -import { amountOf, chainTokenWord } from './parse-action' +import { AMOUNT, amountOf, chainTokenWord } from './parse-action' import type { ChainSequenceRequest, FollowOn } from './sequence' -const AMOUNT = String.raw`\$?([\d,]*\.?\d+)` 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+)` diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index df39df35..814e6be8 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -4,6 +4,7 @@ import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade- import { WRAP_NEAR } from './near/tokens' import { sendRefusal } from './recipient' import { fromBase, toBase } from './sequence' +import { holdingOf } from './sequence-flow' import { ChainActionRefused, tokenBySymbol, @@ -14,6 +15,7 @@ import { 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. */ @@ -191,6 +193,7 @@ export async function prepareChainAction( 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) @@ -219,28 +222,17 @@ const NATIVE_SYMBOL: Record = { const RESERVE_READ_MS = 3000 /** - * 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. + * 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. */ -export async function assertNativeReserve( +async function readBalancesBriefly( 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 - +): Promise { const controller = new AbortController() let timer: ReturnType | undefined - let reading try { - reading = await Promise.race([ + return await Promise.race([ chainApi.balances(account, controller.signal), new Promise((_, reject) => { timer = setTimeout(() => { @@ -250,10 +242,57 @@ export async function assertNativeReserve( }), ]) } catch { - return + 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 diff --git a/apps/dapp/lib/chains/send.ts b/apps/dapp/lib/chains/send.ts index ab0f0b94..b88cf715 100644 --- a/apps/dapp/lib/chains/send.ts +++ b/apps/dapp/lib/chains/send.ts @@ -51,8 +51,11 @@ 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}` -const NOT_ISSUED = `This transfer's details are not what this app issued. ${NOTHING_MOVED}` -const NOT_THE_TRANSFER = `This is not the transfer that was asked for, so it was stopped. ${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." @@ -73,6 +76,9 @@ interface Mover { 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 @@ -154,7 +160,8 @@ const SOLANA_MOVER: Mover = { family: 'solana', tokens: SOLANA_TOKENS, deposit: solanaDeposit, - assertReceiver: assertDepositIsNotTokenAccount, + assertReceiver: (recipient, token) => + assertDepositIsNotTokenAccount(recipient, token, { refusePrograms: true }), notes: [SOLANA_RENT_NOTE], hashOf: (signed) => signed.family === 'solana' ? feePayerSignature(signed.signedTransaction) : undefined, @@ -349,14 +356,18 @@ export function createSendVenue(chain: ChainWithActions): ChainVenue { 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: `This transfer did not go through, so your ${expectation.token?.symbol ?? 'funds'} did not move. You can try again.`, + 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 from the builder's own check comes before it sends. Anything else may be after. + // 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_THE_TRANSFER } + ? { ok: false, error: NOT_SENT } : unconfirmed() } finally { clearTimeout(timer) diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index 3183cb10..dd760054 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -25,6 +25,7 @@ import { SOLANA_TOKENS } from './solana/tokens' import { SUI_TOKENS } from './sui/tokens' import { tokenBySymbol, + type ChainActionKind, type ChainActionRequest, type ChainToken, type ChainWithActions, @@ -172,23 +173,29 @@ export function moveOnCard( * 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. + * 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 + 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') return { stage: 'followed', settled: true } - const ended = phase === 'pending' || phase === 'failed' || phase === 'idle' - if (!ended) return progress - if (phase !== 'pending' && !signed) return { stage: 'ready', settled: true } + 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 ? '' @@ -482,8 +489,14 @@ export function sequenceOutcome( 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 (request.swap.tokenOut ?? '').toUpperCase() + return listedSymbol(request.swap.tokenOut ?? '') } /** @@ -509,7 +522,7 @@ export function unmeasuredReply(request: ChainSequenceRequest): string { export function stepLabels(request: ChainSequenceRequest): [string, string] { const { amount, tokenIn } = request.swap - const first = `Swap ${amount} ${tokenIn.toUpperCase()} to ${outputSymbol(request)}` + const first = `Swap ${amount} ${listedSymbol(tokenIn)} to ${outputSymbol(request)}` return [first, secondLabel(request.followOn)] } diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index d42f071d..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({ @@ -292,12 +294,17 @@ async function usdcBalance(account: string): Promise { */ 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( From 3f73acc2c1df66f853aee50d07bd96fe103027d6 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:10:37 +0100 Subject: [PATCH 17/27] fix(dapp): read no thousands from a leading zero, and no swap from a zero-led cash-out "send 0,500 USDC" meant 0.5 and built a card for 500. Thousands can no longer start with a zero, and a cash-out amount with a leading zero starts no swap off Stellar. A step 2 refusal also reads correctly whatever "so try again" followed its claim. --- .../lib/__tests__/agents-chain-race.test.ts | 15 +++++++++ apps/dapp/lib/__tests__/chain-action.test.ts | 30 ++++++++++++------ .../dapp/lib/__tests__/chain-sequence.test.ts | 31 ++++++++++++++++++- apps/dapp/lib/agents/chain-race.ts | 4 +-- apps/dapp/lib/chains/parse-action.ts | 7 +++-- apps/dapp/lib/weave/parse-cashout.ts | 2 ++ 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts index 5263b315..0ae401a3 100644 --- a/apps/dapp/lib/__tests__/agents-chain-race.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-race.test.ts @@ -314,6 +314,21 @@ describe('stepErrorMessage', () => { ).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.' diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index b4e72ed5..eb2efba1 100644 --- a/apps/dapp/lib/__tests__/chain-action.test.ts +++ b/apps/dapp/lib/__tests__/chain-action.test.ts @@ -59,19 +59,31 @@ describe('parseChainAction', () => { }) }) - it.each(['1,5', '0,5', '1,50', '10,00', '1,0000', ',5', '1,,000', '1000,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,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'], diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index 940956aa..9ebaa7c4 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -86,7 +86,7 @@ describe('a token read beside a currency we do not pay', () => { 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']) { + 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 => { @@ -121,6 +121,35 @@ describe('parseChainSequence strictness', () => { }) 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', diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index a7b904bb..d28cacce 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -252,8 +252,8 @@ export function stepErrorMessage( if (message === undefined || !sequenceSettled) return message if (!/nothing was signed or sent/i.test(message)) return message const rest = message - .replace(/,\s*so nothing was signed or sent\.?/gi, '.') - .replace(/\s*Nothing was signed or sent(?:, so try again in a moment)?\.?/g, '') + .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}` } diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index 53d01a7c..97329811 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -1,10 +1,11 @@ import type { ChainActionRequest } from './types' /** - * Commas only as thousands, 1,000 or 1,000.5. In "1,5" a comma is a decimal point, and dropping it - * would read ten times the amount, so that is no amount at all and the person is told what works. + * 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`\$?((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)` +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*$` diff --git a/apps/dapp/lib/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index 802df179..6ac1c020 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -259,8 +259,10 @@ export function routeCashout( } if ('unsupportedToken' in read) { if (chain === 'stellar' && !weaveOnly(read.currency)) return { kind: 'pass' } + // A leading zero may be a decimal comma, "0,500" read as 0500, and a swap would take it as 500. if ( read.amount !== undefined && + !/^0\d/.test(read.amount) && (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) ) { return { From 8a797d250fddb226d98251e24fd5a0814e443bed Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:37:17 +0100 Subject: [PATCH 18/27] feat(dapp): read multi-step requests in ordinary words on every chain The orchestrator's reading now reaches the same path as a typed sentence off Stellar. Its amount, token and any recipient must be in the person's own turns, character for character, or it is refused in words. The fact sheet, help text and prompt say what each chain can run, and a leading-zero amount in the plain cash-out form is dropped off Stellar so the person types it. --- apps/dapp/components/intents/intent-chat.tsx | 71 ++++-- .../lib/__tests__/chain-sequence-flow.test.ts | 10 + .../dapp/lib/__tests__/chain-sequence.test.ts | 203 +++++++++++++++++- apps/dapp/lib/__tests__/evm-module.test.ts | 2 +- .../orchestrate-capabilities-chain.test.ts | 113 +++++++++- apps/dapp/lib/__tests__/sui-module.test.ts | 2 +- apps/dapp/lib/chains/evm/capabilities.ts | 4 +- apps/dapp/lib/chains/explain.ts | 23 +- apps/dapp/lib/chains/near/capabilities.ts | 2 +- apps/dapp/lib/chains/parse-sequence.ts | 199 ++++++++++++++++- apps/dapp/lib/chains/sequence-flow.ts | 6 +- apps/dapp/lib/chains/solana/capabilities.ts | 2 +- apps/dapp/lib/chains/sui/capabilities.ts | 2 +- apps/dapp/lib/orchestrate/capabilities.ts | 64 +++++- apps/dapp/lib/orchestrate/orchestrate.ts | 14 +- apps/dapp/lib/weave/parse-cashout.ts | 10 +- 16 files changed, 677 insertions(+), 50 deletions(-) diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index cb90aa40..d2fad5be 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -82,7 +82,7 @@ import { balanceReplyDecision, chainBalanceReply } from '../../lib/chains/balanc import { chainCapabilities } from '../../lib/chains/capabilities' import { chainHelp } from '../../lib/chains/explain' import { looksLikeChainAction, parseChainAction, racesFirst } from '../../lib/chains/parse-action' -import { parseChainSequence } from '../../lib/chains/parse-sequence' +import { chainRouteFromReading, parseChainSequence } from '../../lib/chains/parse-sequence' import { followOnRefusal, type ChainSequenceRequest } from '../../lib/chains/sequence' import { CASHOUT_UNDER_WAY, @@ -1755,6 +1755,30 @@ export function IntentChat({ setChainRace({ request: request.swap, text }) } + /** 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 { + // 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) + 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) @@ -2064,26 +2088,7 @@ export function IntentChat({ } 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. - 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() - 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) - } + startChainAction(action, text) return } if (looksLikeChainAction(text)) { @@ -2271,6 +2276,30 @@ export function IntentChat({ 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. + const chainCaps = chainCapabilities(slug) + if (chainCaps !== undefined) { + // A swap must not start under whatever was said while this was being read. + if (asked.turn !== latest.current.turn || asked.chain !== latest.current.chain) return + const shown = turns[0]?.content ?? text + const said = turns.filter((turn) => turn.role === 'user').map((turn) => turn.content) + const route = chainRouteFromReading(answer.read, answer.instruction, said) + if (route !== null && 'sequence' in route) { + startChainSequence(route.sequence, shown) + } else if (route !== null && 'action' in route) { + startChainAction(route.action, shown) + } else { + setMessage(shown) + clearChainRace() + chainAction.refuse( + route !== null ? route.refusal : chainHelp(descriptor.name, chainCaps), + shown + ) + } + return + } + // Tokens the server proved for this request, so the page can trade them // without a hand-written entry. for (const asset of answer.read.liveAssets) { diff --git a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts index a42076b1..6423ee85 100644 --- a/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence-flow.test.ts @@ -975,6 +975,16 @@ describe('sequenceWordsMatch', () => { 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) diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index 9ebaa7c4..c9196386 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest' import { assertNativeReserve } from '../chains/prepare' -import { parseChainSequence } from '../chains/parse-sequence' +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, @@ -418,3 +426,196 @@ describe('assertNativeReserve', () => { ).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 a dollar amount, 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 dollars']) + ).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() + }) +}) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 089ab582..d6ce5c19 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -438,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') }) diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index 575e44d6..55270dba 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,112 @@ 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:.*every token/) + } + }, 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).toMatch(/character for character/) + expect(system).toMatch(/Swap 5 SOL to USDC, then send to/) }) }) diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index a0c0637e..01863a6e 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -309,7 +309,7 @@ describe('the module', () => { 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/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/explain.ts b/apps/dapp/lib/chains/explain.ts index 89a523eb..1e078668 100644 --- a/apps/dapp/lib/chains/explain.ts +++ b/apps/dapp/lib/chains/explain.ts @@ -7,10 +7,21 @@ const EXAMPLE: Record string 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. */ @@ -18,6 +29,16 @@ 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 ')}.` } 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/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index 0e3c877e..ccaa8310 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -1,6 +1,8 @@ import { parseCashout } from '../weave/parse-cashout' -import { AMOUNT, amountOf, chainTokenWord } from './parse-action' +import { AMOUNT, amountOf, chainTokenWord, parseChainAction } from './parse-action' import type { ChainSequenceRequest, FollowOn } from './sequence' +import { sequenceWordsMatch } from './sequence-flow' +import type { ChainActionRequest } 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+)` @@ -59,20 +61,197 @@ export function parseChainSequence( const followOn = followOnOf((match[4] as string).trim()) if (followOn === null) return null if ('refusal' in followOn) return followOn - const tokenOut = chainTokenWord(match[3] as string) + return withSwap( + { + kind: 'swap', + amount: amountOf(match[1] as string), + tokenIn: chainTokenWord(match[2] as string), + 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 } +} + +/** + * 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 +} + +const EXAMPLE_AFTER: Record = { + offramp: ' then cash out', + lend: ' then deposit', + send: ' then send to an address', +} + +function refuseUnsaid(read: ReadSwap, what: string): { refusal: string } { + const after = read.followOn === null ? '' : (EXAMPLE_AFTER[read.followOn.kind] ?? '') return { - swap: { - kind: 'swap', - amount: amountOf(match[1] as string), - tokenIn: chainTokenWord(match[2] as string), - tokenOut, - venue: undefined, - }, - followOn, + 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 "swap 5 ${chainTokenWord(read.tokenIn)} to ${chainTokenWord(read.tokenOut)}${after}". 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.amountIsUsd === true || + read.amountOf === 'received' || + !/^\d+(?:\.\d+)?$/.test(amount) + ) { + return refuseUnsaid(read, 'an amount of the token you are swapping') + } + return { + kind: 'swap', + amount, + tokenIn: chainTokenWord(read.tokenIn), + tokenOut: chainTokenWord(read.tokenOut), + venue: undefined, + } +} + +/** 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[]): boolean { + if (!sequenceWordsMatch({ amount: request.amount, token: request.tokenIn }, 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 + if (model === null) return null + const swap = swapOf(read) + if ('refusal' in swap) return swap + if (!requestSaid(swap, said)) return refuseUnsaid(read, 'that amount and token') + + let followOn: FollowOn + if (model.kind === 'offramp') { + followOn = { kind: 'cashout' } + } else if (model.kind === 'lend') { + if (model.venueNamed === true && !said.some((t) => t.toLowerCase().includes(model.venue))) { + 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 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 and token') +} + +/** 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`. 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) { + 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) && recipientSaid + ? { sequence } + : refuseUnsaid( + { + ...read, + followOn: { kind: followOn.kind === 'cashout' ? 'offramp' : 'send', venue: '' }, + }, + 'that request' + ) + } + const action = parseChainAction(instruction) + if (action !== null) { + return requestSaid(action, said) + ? { action } + : refuseUnsaid( + { + ...read, + tokenIn: action.tokenIn, + tokenOut: action.tokenOut ?? action.tokenIn, + followOn: null, + }, + 'that request' + ) } + if (read.action !== 'swap') return null + const swap = swapFromReading(read, said) + return 'refusal' in swap ? swap : { action: swap } } diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index dd760054..386a6442 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -697,8 +697,12 @@ export function sequenceWordsMatch( String.raw`\b(?:on|in|from|to|into|at|via|using|through)\s+(?:my\s+|the\s+)?(?:${names.join('|')})\b|\b(?:${names.join('|')})\s+(?:wallet|account|address|chain|network|blockchain)\b`, 'gi' ) + // "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.,$₦£€])(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)(?!\s*(?:${NOT_THE_AMOUNT})(?![\w+]))`, + String.raw`(?:^|[^\w.,$₦£€])(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)(?!\s*(?:${notAmount})(?![\w+]))`, 'gi' ) const wanted = canonicalNumber(instruction.amount) 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/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/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index 6f4b134b..c63228a1 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,20 @@ 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.", +} + +const OWN_TOKEN: Record = { + solana: 'SOL', + sui: 'SUI', + near: 'NEAR', + avalanche: 'AVAX', + monad: 'MON', +} + const NOTHING_RUNS = 'Nothing can be swapped, supplied or staked on this chain right now.' function bullets(lines: string[]): string { @@ -48,8 +65,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 +83,44 @@ 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.', + 'Balance: ask what you hold and the wallet is listed token by token, every token it holds, with those Intent cannot trade marked unlisted.' + ) + return bullets(lines) } diff --git a/apps/dapp/lib/orchestrate/orchestrate.ts b/apps/dapp/lib/orchestrate/orchestrate.ts index 340cdbdd..84531c6f 100644 --- a/apps/dapp/lib/orchestrate/orchestrate.ts +++ b/apps/dapp/lib/orchestrate/orchestrate.ts @@ -209,10 +209,22 @@ const BASE_PROMPT = [ '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. + */ +const OFF_STELLAR_PROMPT = [ + 'The person is not on Stellar: the app runs on the chain named in the facts below, so ignore the Stellar network, Blend, DeFindex and anchors above.', + "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 SOL 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 base = cashOut === '' ? BASE_PROMPT : `${BASE_PROMPT} ${cashOut}` + return chain === 'stellar' ? base : `${base} ${OFF_STELLAR_PROMPT}` } /** Long enough for any sentence a person types here, short enough to bound what is passed on. */ diff --git a/apps/dapp/lib/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index 6ac1c020..ba66ec9c 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -274,14 +274,20 @@ export function routeCashout( } return { kind: 'token', token: read.unsupportedToken } } + // Off Stellar a leading zero may be a decimal comma, "0,500" read as 0500: the form opens + // with no seed and the person types the amount. + const seed = + read.amount !== undefined && (chain === 'stellar' || !/^0\d/.test(read.amount)) + ? { amount: read.amount } + : {} if (read.currency === undefined) { if (chain === 'stellar' && !stellarMainnet) return { kind: 'pass' } - return { kind: 'ask', ...(read.amount !== undefined ? { amount: read.amount } : {}) } + return { kind: 'ask', ...seed } } if (chain === 'stellar' && !weaveOnly(read.currency)) return { kind: 'pass' } return { kind: 'open', - ...(read.amount !== undefined ? { amount: read.amount } : {}), + ...seed, ...(read.currency !== undefined ? { currency: read.currency } : {}), } } From f7cee9a1e8a278beea68ba61217a2fb971be2e58 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:53:57 +0100 Subject: [PATCH 19/27] feat(dapp): hold a chain's readings to its own tokens, and gate the token out on the person's words Off Stellar the orchestrator route allows the chain module's tokens, coin and name words, and its deposit venues, instead of Stellar's registry, and Weave as the one payout where a swap can end in a cash-out. A model's output token must be one the person named, except USDC before a cash-out. --- apps/dapp/app/api/intent/orchestrate/route.ts | 34 +++++++--- .../dapp/lib/__tests__/chain-sequence.test.ts | 50 ++++++++++++++- .../intent-read-policy-chain.test.ts | 63 +++++++++++++++++++ .../orchestrate-capabilities-chain.test.ts | 2 + .../lib/__tests__/orchestrate-route.test.ts | 42 ++++++++++++- apps/dapp/lib/chains/parse-sequence.ts | 33 +++++++--- apps/dapp/lib/chains/sequence-flow.ts | 55 +++++++++++----- apps/dapp/lib/intent-read-policy.ts | 20 ++++++ apps/dapp/lib/orchestrate/orchestrate.ts | 9 ++- 9 files changed, 273 insertions(+), 35 deletions(-) create mode 100644 apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts diff --git a/apps/dapp/app/api/intent/orchestrate/route.ts b/apps/dapp/app/api/intent/orchestrate/route.ts index 8c4f7978..5d5e7222 100644 --- a/apps/dapp/app/api/intent/orchestrate/route.ts +++ b/apps/dapp/app/api/intent/orchestrate/route.ts @@ -3,13 +3,16 @@ import { z } from 'zod' import { LENDABLE_ONLY, + chainReadPolicy, 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 +77,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 +91,22 @@ 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'] + : [], }) 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 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 }) @@ -170,10 +183,13 @@ export async function POST(request: Request): Promise { 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/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index c9196386..0fc2928d 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -606,7 +606,7 @@ describe('chainRouteFromReading', () => { action: { kind: 'send', recipient: ADDRESS }, }) expect( - chainRouteFromReading(read(), 'Flip 5 SOL into USDC', ['flip 5 sol into dollars']) + 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', [ @@ -619,3 +619,51 @@ describe('chainRouteFromReading', () => { 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') + }) +}) 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..5a5e6d96 --- /dev/null +++ b/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { chainReadPolicy } 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') + }) +}) diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index 55270dba..bf85713c 100644 --- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -187,6 +187,8 @@ describe('the orchestrator prompt', () => { 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/) }) diff --git a/apps/dapp/lib/__tests__/orchestrate-route.test.ts b/apps/dapp/lib/__tests__/orchestrate-route.test.ts index 2d7499f8..487c1781 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,35 @@ 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() + }) + + 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/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index ccaa8310..c1be65dc 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -1,7 +1,7 @@ import { parseCashout } from '../weave/parse-cashout' import { AMOUNT, amountOf, chainTokenWord, parseChainAction } from './parse-action' import type { ChainSequenceRequest, FollowOn } from './sequence' -import { sequenceWordsMatch } from './sequence-flow' +import { sequenceWordsMatch, tokenSaid } from './sequence-flow' import type { ChainActionRequest } from './types' const TOKEN = String.raw`([a-z][a-z0-9.+]{0,11})` @@ -110,7 +110,7 @@ const EXAMPLE_AFTER: Record = { function refuseUnsaid(read: ReadSwap, what: string): { refusal: string } { const after = read.followOn === null ? '' : (EXAMPLE_AFTER[read.followOn.kind] ?? '') 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 "swap 5 ${chainTokenWord(read.tokenIn)} to ${chainTokenWord(read.tokenOut)}${after}". Nothing was signed or sent.`, + 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 "swap 5 ${chainTokenWord(read.tokenIn)} to USDC${after}". Nothing was signed or sent.`, } } @@ -134,6 +134,12 @@ function swapOf(read: ReadSwap): ChainActionRequest | { refusal: string } { } } +/** 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 @@ -148,8 +154,19 @@ function typedExactly(recipient: string, said: readonly string[]): boolean { * 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[]): boolean { +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 { if (!sequenceWordsMatch({ amount: request.amount, token: request.tokenIn }, said)) return false + const out = request.tokenOut + const outSaid = + out === undefined || + tokenSaid(out, said, true) || + (usdcImplied && chainTokenWord(out).toUpperCase() === 'USDC') + if (!outSaid) return false return request.recipient === undefined || typedExactly(request.recipient, said) } @@ -166,13 +183,15 @@ export function sequenceFromReading( if (model === null) return null const swap = swapOf(read) if ('refusal' in swap) return swap - if (!requestSaid(swap, said)) return refuseUnsaid(read, 'that amount and token') + if (!requestSaid(swap, said, model.kind === 'offramp')) { + return refuseUnsaid(read, 'that amount, token and token to receive') + } let followOn: FollowOn if (model.kind === 'offramp') { followOn = { kind: 'cashout' } } else if (model.kind === 'lend') { - if (model.venueNamed === true && !said.some((t) => t.toLowerCase().includes(model.venue))) { + if (model.venueNamed === true && !venueSaid(model.venue, said)) { return refuseUnsaid(read, 'that venue') } followOn = @@ -196,7 +215,7 @@ export function swapFromReading( if ('refusal' in swap) return swap return requestSaid(swap, said) ? swap - : refuseUnsaid({ ...read, followOn: null }, 'that amount and token') + : refuseUnsaid({ ...read, followOn: null }, 'that amount, token and token to receive') } /** What the chat does with an act the orchestrator read off Stellar. */ @@ -227,7 +246,7 @@ export function chainRouteFromReading( if ('refusal' in sequence) return sequence const { followOn } = sequence const recipientSaid = followOn.kind !== 'send' || typedExactly(followOn.recipient, said) - return requestSaid(sequence.swap, said) && recipientSaid + return requestSaid(sequence.swap, said, followOn.kind === 'cashout') && recipientSaid ? { sequence } : refuseUnsaid( { diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index 386a6442..01e219d0 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -685,18 +685,6 @@ export function sequenceWordsMatch( turns: readonly string[] ): boolean { const symbol = chainTokenWord(instruction.token) - const names = chainNamesOf(symbol) - const token = [symbol, ...names].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') - const wordEnd = String.raw`(?![A-Za-z0-9+]|\.[A-Za-z0-9])` - const glued = new RegExp(String.raw`(\d)(${token})${wordEnd}`, 'gi') - const word = new RegExp(String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})${wordEnd}`, 'i') - const place = - names.length === 0 - ? undefined - : new RegExp( - String.raw`\b(?:on|in|from|to|into|at|via|using|through)\s+(?:my\s+|the\s+)?(?:${names.join('|')})\b|\b(?:${names.join('|')})\s+(?:wallet|account|address|chain|network|blockchain)\b`, - 'gi' - ) // "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()) @@ -706,15 +694,52 @@ export function sequenceWordsMatch( 'gi' ) const wanted = canonicalNumber(instruction.amount) - const spaced = turns.map((turn) => turn.replace(glued, '$1 $2').replace(/([$₦£€])\s+/g, '$1')) return ( - spaced.some((turn) => word.test(place === undefined ? turn : turn.replace(place, ' '))) && - spaced.some((turn) => + tokenSaid(instruction.token, turns) && + spacedTurns(symbol, turns).some((turn) => [...turn.matchAll(number)].some((match) => canonicalNumber(match[1] as string) === wanted) ) ) } +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, ' ')) + ) +} + /** * 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. diff --git a/apps/dapp/lib/intent-read-policy.ts b/apps/dapp/lib/intent-read-policy.ts index 6f465868..757fdbb8 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,21 @@ 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 } +} diff --git a/apps/dapp/lib/orchestrate/orchestrate.ts b/apps/dapp/lib/orchestrate/orchestrate.ts index 84531c6f..ff86d151 100644 --- a/apps/dapp/lib/orchestrate/orchestrate.ts +++ b/apps/dapp/lib/orchestrate/orchestrate.ts @@ -1,3 +1,4 @@ +import { chainModule } from '../chains/registry' import { cashOutPrompt } from '../weave/corridors' import { tidyProse } from '../chat/prose' import { intentModels, type IntentModel } from '../llm/intent-models' @@ -224,7 +225,13 @@ const OFF_STELLAR_PROMPT = [ function systemPrompt(chain: string): string { const cashOut = cashOutPrompt(chain) const base = cashOut === '' ? BASE_PROMPT : `${BASE_PROMPT} ${cashOut}` - return chain === 'stellar' ? base : `${base} ${OFF_STELLAR_PROMPT}` + 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} ${OFF_STELLAR_PROMPT}${tokens}` } /** Long enough for any sentence a person types here, short enough to bound what is passed on. */ From 68ffc8e7aff9a7ef1084fe7b0fe5f87ab40d7b43 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:10:34 +0100 Subject: [PATCH 20/27] fix(dapp): keep a swap's order in the person's words, and stop telling other chains about stellar The amount must sit by the token given up and the token received must come after it, read across the person's turns in order. The prompt is built per chain, the balance line says what each chain reads, a venue the model adds to a plain action is dropped unless typed, and a spelling a chain has a hint for is answered with the hint. --- apps/dapp/app/api/intent/orchestrate/route.ts | 6 + .../dapp/lib/__tests__/chain-sequence.test.ts | 64 ++++++++++ .../intent-read-policy-chain.test.ts | 15 ++- .../orchestrate-capabilities-chain.test.ts | 41 ++++++- apps/dapp/lib/chains/parse-sequence.ts | 26 ++-- apps/dapp/lib/chains/sequence-flow.ts | 37 ++++++ apps/dapp/lib/intent-read-policy.ts | 12 ++ apps/dapp/lib/orchestrate/capabilities.ts | 15 ++- apps/dapp/lib/orchestrate/orchestrate.ts | 112 +++++++++++------- 9 files changed, 272 insertions(+), 56 deletions(-) diff --git a/apps/dapp/app/api/intent/orchestrate/route.ts b/apps/dapp/app/api/intent/orchestrate/route.ts index 5d5e7222..071d955b 100644 --- a/apps/dapp/app/api/intent/orchestrate/route.ts +++ b/apps/dapp/app/api/intent/orchestrate/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { LENDABLE_ONLY, chainReadPolicy, + tokenHintIn, OFFRAMP_ANCHORS, lendingVenuesOn, unknownTickers, @@ -106,6 +107,11 @@ export async function POST(request: Request): Promise { 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 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' }) diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index 0fc2928d..7b48668b 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -667,3 +667,67 @@ describe('the output token of a model reading', () => { ).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__/intent-read-policy-chain.test.ts b/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts index 5a5e6d96..87280e91 100644 --- a/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts +++ b/apps/dapp/lib/__tests__/intent-read-policy-chain.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { chainReadPolicy } from '../intent-read-policy' +import { chainReadPolicy, tokenHintIn } from '../intent-read-policy' import { readingFromArguments } from '../parse-intent-llm' const args = (over: Record = {}) => ({ @@ -61,3 +61,16 @@ describe('chainReadPolicy', () => { 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__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index bf85713c..a567b763 100644 --- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -143,7 +143,8 @@ describe('capabilitySheet multi-step forms', () => { 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:.*every token/) + expect(sheet(chain)).toMatch(/Balance:/) + expect(sheet(chain)).not.toContain('unlisted') } }, 60_000) @@ -193,3 +194,41 @@ describe('the orchestrator prompt', () => { 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/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index c1be65dc..305548ab 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -1,7 +1,7 @@ import { parseCashout } from '../weave/parse-cashout' import { AMOUNT, amountOf, chainTokenWord, parseChainAction } from './parse-action' import type { ChainSequenceRequest, FollowOn } from './sequence' -import { sequenceWordsMatch, tokenSaid } from './sequence-flow' +import { sequenceWordsMatch, swapOrderSaid } from './sequence-flow' import type { ChainActionRequest } from './types' const TOKEN = String.raw`([a-z][a-z0-9.+]{0,11})` @@ -162,11 +162,18 @@ export function requestSaid( ): boolean { if (!sequenceWordsMatch({ amount: request.amount, token: request.tokenIn }, said)) return false const out = request.tokenOut - const outSaid = + // 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 || - tokenSaid(out, said, true) || - (usdcImplied && chainTokenWord(out).toUpperCase() === 'USDC') - if (!outSaid) return false + (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) } @@ -258,8 +265,13 @@ export function chainRouteFromReading( } const action = parseChainAction(instruction) if (action !== null) { - return requestSaid(action, said) - ? { action } + // 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, diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index 01e219d0..ed62134b 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -740,6 +740,43 @@ export function tokenSaid(token: string, turns: readonly string[], destination = ) } +/** + * 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 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 (canonicalNumber(match[1] as string) === wanted) + ends.push((match.index ?? 0) + match[0].length) + } + for (const match of joined.matchAll(after)) { + if (canonicalNumber(match[1] as string) === wanted) + ends.push((match.index ?? 0) + match[0].length) + } + if (tokenOut === undefined) return ends.length > 0 + return ends.some((end) => tokenSaid(tokenOut, [joined.slice(end)], 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. diff --git a/apps/dapp/lib/intent-read-policy.ts b/apps/dapp/lib/intent-read-policy.ts index 757fdbb8..292c33d1 100644 --- a/apps/dapp/lib/intent-read-policy.ts +++ b/apps/dapp/lib/intent-read-policy.ts @@ -97,3 +97,15 @@ export function chainReadPolicy( 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 c63228a1..f06bbb4e 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -43,7 +43,7 @@ const NATIVE_NOT_SENT: Record = { near: "Native NEAR can't be sent yet; USDC and the listed tokens can.", } -const OWN_TOKEN: Record = { +export const OWN_TOKEN: Record = { solana: 'SOL', sui: 'SUI', near: 'NEAR', @@ -51,6 +51,14 @@ const OWN_TOKEN: Record = { 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 { @@ -118,9 +126,6 @@ export function capabilitySheet(chain: string): string { lines.push('Cash out and deposit after a swap do not run on Monad.') } } - lines.push( - 'Limit orders are not available off Stellar.', - 'Balance: ask what you hold and the wallet is listed token by token, every token it holds, with those Intent cannot trade marked unlisted.' - ) + 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 ff86d151..f157e3fd 100644 --- a/apps/dapp/lib/orchestrate/orchestrate.ts +++ b/apps/dapp/lib/orchestrate/orchestrate.ts @@ -2,7 +2,7 @@ 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, @@ -175,63 +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. */ -const OFF_STELLAR_PROMPT = [ - 'The person is not on Stellar: the app runs on the chain named in the facts below, so ignore the Stellar network, Blend, DeFindex and anchors above.', - "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 SOL 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(' ') +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) - const base = 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} ${OFF_STELLAR_PROMPT}${tokens}` + return `${base} ${offStellarPrompt(chain)}${tokens}` } /** Long enough for any sentence a person types here, short enough to bound what is passed on. */ From 0d42660ec5d0d95ffa26791b0f50ab64c8423f47 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:03:25 +0100 Subject: [PATCH 21/27] fix(dapp): keep step 2 from offering the swap again, and stop near cards past their deadline An expired step 2 card told the person to send their request again, which offers a second swap. It now points at the button, which is there because an expired card proved nothing moved. A wallet that gave no answer has no button, so that sentence says to check and not to send the request again. NEAR's wallet sends for itself, so its deadline is checked before the wallet opens, as EVM's is. --- apps/dapp/lib/__tests__/evm-module.test.ts | 9 +++++++-- apps/dapp/lib/agents/chain-race.ts | 16 +++++++++++++++- apps/dapp/lib/chains/signing-flow.ts | 11 +++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index d6ce5c19..583b30e9 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -1730,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/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index d28cacce..c7d8afab 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' @@ -241,15 +242,28 @@ export function recommendationLine( 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." + /** * 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. + * 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. */ export function stepErrorMessage( message: string | undefined, sequenceSettled: boolean ): 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)}${STEP_UNCONFIRMED}` + } 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, '.') diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index 14575136..7b86cc2e 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 From d37502363f85151921368905a30cc10b94ca3c12 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:03:40 +0100 Subject: [PATCH 22/27] fix(dapp): report a follow-on the server can't honour, off stellar only No anchors, no venues or an unknown venue dropped the follow-on and left a plain swap to sign. Off Stellar the reading now says what was refused, and a venue's name matches whatever its spaces and hyphens. Stellar's options, reading and prompt are unchanged. --- apps/dapp/app/api/intent/orchestrate/route.ts | 5 +++ .../lib/__tests__/orchestrate-route.test.ts | 13 +++++++ apps/dapp/lib/chat/orchestrate-client.ts | 10 ++++++ apps/dapp/lib/parse-intent-llm.ts | 36 ++++++++++++++++--- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/dapp/app/api/intent/orchestrate/route.ts b/apps/dapp/app/api/intent/orchestrate/route.ts index 071d955b..b206bab4 100644 --- a/apps/dapp/app/api/intent/orchestrate/route.ts +++ b/apps/dapp/app/api/intent/orchestrate/route.ts @@ -102,6 +102,8 @@ export async function POST(request: Request): Promise { 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) { @@ -183,6 +185,9 @@ 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 } : {}), diff --git a/apps/dapp/lib/__tests__/orchestrate-route.test.ts b/apps/dapp/lib/__tests__/orchestrate-route.test.ts index 487c1781..cb8a87bf 100644 --- a/apps/dapp/lib/__tests__/orchestrate-route.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-route.test.ts @@ -203,6 +203,19 @@ describe('the vocabulary a reading is held to', () => { 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 () => { diff --git a/apps/dapp/lib/chat/orchestrate-client.ts b/apps/dapp/lib/chat/orchestrate-client.ts index 6f982ecf..c2f4d80e 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,13 @@ function strings(value: unknown): string[] { return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] } +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. * @@ -111,6 +120,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/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 } From 2e7f0dc1b8ec5c2c740aaf67183319beb025a5fc Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:03:53 +0100 Subject: [PATCH 23/27] fix(dapp): send ordinary words to the model, and hold step 2 to what the person said readChainMessage now decides: only what the strict parsers read in full stays out of the model, and a verb no longer gets a canned answer. A currency as the token out is a cash-out or its refusal. A follow-on that can't run is refused before step 1, and a model can't choose step 2's venue. The gate takes the last figure stated. A question keeps the run and its button, and an action ends it. Unrelated money arriving no longer inflates a send, deposit or stake. --- .../intents/chain-sequence-steps.tsx | 14 +- apps/dapp/components/intents/intent-chat.tsx | 136 ++-- apps/dapp/hooks/use-chain-sequence.ts | 13 +- .../__tests__/chain-sequence-final.test.ts | 648 ++++++++++++++++++ .../dapp/lib/__tests__/chain-sequence.test.ts | 2 +- .../lib/__tests__/weave-cashout-view.test.ts | 2 +- apps/dapp/lib/chains/explain.ts | 13 + apps/dapp/lib/chains/parse-action.ts | 2 +- apps/dapp/lib/chains/parse-sequence.ts | 190 ++++- apps/dapp/lib/chains/sequence-flow.ts | 120 +++- apps/dapp/lib/chains/sequence.ts | 3 +- apps/dapp/lib/weave/cashout-view.ts | 2 +- apps/dapp/lib/weave/parse-cashout.ts | 17 +- 13 files changed, 1070 insertions(+), 92 deletions(-) create mode 100644 apps/dapp/lib/__tests__/chain-sequence-final.test.ts diff --git a/apps/dapp/components/intents/chain-sequence-steps.tsx b/apps/dapp/components/intents/chain-sequence-steps.tsx index 47b0be40..ed4454c3 100644 --- a/apps/dapp/components/intents/chain-sequence-steps.tsx +++ b/apps/dapp/components/intents/chain-sequence-steps.tsx @@ -9,6 +9,7 @@ import { cashoutAmountFrom } from '../../lib/chains/sequence' import { deliveredLine, secondStepLabel, + stepAmount, stepLabels, stepMarks, type StepMark, @@ -55,8 +56,11 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele stage === 'ready' && delivered !== undefined && sequence.followOn.kind === 'cashout' ? cashoutAmountFrom(delivered) : undefined - const ownCard = - stage === 'ready' && delivered !== undefined ? secondStepLabel(sequence, delivered) : undefined + const step = + stage === 'ready' && delivered !== undefined + ? stepAmount(sequence, delivered, steps.quotedOut) + : undefined + const ownCard = step !== undefined ? secondStepLabel(sequence, step.amount) : undefined return (
    @@ -97,6 +101,12 @@ export function ChainSequenceSteps({ steps }: { steps: ChainSequence }): JSX.Ele

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

    + {step.note} +

    + ) : null} + {offer !== undefined ? (
    ) : null} diff --git a/apps/dapp/lib/__tests__/chain-sequence-final.test.ts b/apps/dapp/lib/__tests__/chain-sequence-final.test.ts index 068c7a67..928cc3e1 100644 --- a/apps/dapp/lib/__tests__/chain-sequence-final.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence-final.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { STEP_CARD_EXPIRED, stepErrorMessage } from '../agents/chain-race' import { chainCapabilities } from '../chains/capabilities' -import { chainHelp, helpForUnread } from '../chains/explain' +import { chainHelp, helpForUnread, RATE_LIMITED } from '../chains/explain' import { looksLikeChainAction } from '../chains/parse-action' import { chainRouteFromReading, @@ -20,6 +20,7 @@ import { followOnRefusal, type ChainSequenceRequest } from '../chains/sequence' import { keepsRun, moveOnSecond, + stepOnItsOwn, secondStepLabel, stepAmount, type SequenceStage, @@ -27,6 +28,7 @@ import { 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' @@ -116,8 +118,8 @@ describe('2. ordinary words reach the model', () => { 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(['swap some sol'], 'Solana', capabilities)).toBe(help) - expect(helpForUnread(['how are you'], 'Solana', capabilities)).toBeUndefined() + 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) }) }) @@ -568,7 +570,7 @@ describe('11. sentences that say what is true', () => { ['supply it'] ) as { refusal: string } expect(plain.refusal).not.toMatch(/USDC to USDC/) - expect(plain.refusal).toContain('"5 USDC"') + expect(plain.refusal).toContain('"deposit 5 USDC"') const other = sequenceFromReading( read({ tokenIn: 'USDC', @@ -646,3 +648,137 @@ describe('the parsers a sentence goes through first', () => { 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/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index c7d8afab..fdc79ad8 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -250,19 +250,27 @@ export const STEP_CARD_EXPIRED = 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. + * 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 + 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)}${STEP_UNCONFIRMED}` + return `${message.slice(0, -START_AGAIN.length)}${stepUnconfirmed(typedStep)}` } if (!/nothing was signed or sent/i.test(message)) return message const rest = message diff --git a/apps/dapp/lib/chains/explain.ts b/apps/dapp/lib/chains/explain.ts index 7d6d5c14..805905bd 100644 --- a/apps/dapp/lib/chains/explain.ts +++ b/apps/dapp/lib/chains/explain.ts @@ -44,14 +44,22 @@ export function chainHelp(chainName: string, capabilities: readonly ChainCapabil 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 could not read a message that asks to do something. Nothing for one - * that doesn't, which gets the ordinary answer about the model being out of reach. + * 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/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index 27678b67..e4e4d6be 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -13,7 +13,7 @@ import { type FollowOn, } from './sequence' import { sequenceWordsMatch, swapOrderSaid } from './sequence-flow' -import type { ChainActionRequest, ChainWithActions } from './types' +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+)` @@ -203,19 +203,27 @@ const EXAMPLE_AFTER: Record = { } /** An example that could run: USDC can't be swapped to itself, and goes to a bank with no swap. */ -function exampleOf(read: ReadSwap): string { +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) - return tokenOut.toUpperCase() === 'USDC' ? '5 USDC' : `swap 5 USDC to ${tokenOut}${after}` + 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): { refusal: string } { +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)}". Nothing was signed or sent.`, + 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.`, } } @@ -353,6 +361,19 @@ function ownFollowOn(followOn: FollowOn, said: readonly string[]): 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)) @@ -418,8 +439,9 @@ export function chainRouteFromReading( 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: 'send', venue: '' } }, + { ...read, followOn: kind === undefined ? null : { kind, venue: '' } }, 'what to do after the swap' ) } @@ -438,7 +460,8 @@ export function chainRouteFromReading( tokenOut: action.tokenOut ?? action.tokenIn, followOn: null, }, - 'that request' + 'that request', + action.kind ) } const swap = swapFromReading(read, said) diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index e77536c7..6172a5cb 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -619,6 +619,24 @@ export function stepAmount( } } +/** + * 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. */ @@ -702,7 +720,8 @@ const NOT_THE_AMOUNT = /** A figure, what follows it in its turn, and everything said after it. */ interface Figure { - value: string + /** 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 } @@ -726,7 +745,7 @@ function figuresSaid(symbol: string, turns: readonly string[]): Figure[] { [...turn.matchAll(number)].map((match) => { const after = turn.slice((match.index ?? 0) + match[0].length) return { - value: canonicalNumber(match[1] as string), + value: figureValue(match[1] as string), after, rest: [after, ...spaced.slice(index + 1)].join(' '), } @@ -737,6 +756,17 @@ function figuresSaid(symbol: string, turns: readonly string[]): Figure[] { /** 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+$/, '') @@ -830,12 +860,10 @@ export function swapOrderSaid( const wanted = canonicalNumber(amount) const ends: number[] = [] for (const match of joined.matchAll(before)) { - if (canonicalNumber(match[1] as string) === wanted) - ends.push((match.index ?? 0) + match[0].length) + if (figureValue(match[1] as string) === wanted) ends.push((match.index ?? 0) + match[0].length) } for (const match of joined.matchAll(after)) { - if (canonicalNumber(match[1] as string) === wanted) - ends.push((match.index ?? 0) + match[0].length) + 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 diff --git a/apps/dapp/lib/chat/orchestrate-client.ts b/apps/dapp/lib/chat/orchestrate-client.ts index c2f4d80e..7a8c4698 100644 --- a/apps/dapp/lib/chat/orchestrate-client.ts +++ b/apps/dapp/lib/chat/orchestrate-client.ts @@ -43,6 +43,17 @@ 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 {} @@ -75,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 From 4d071dca6d5d56ec278aadfda5ac04618841cbcb Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:39:08 +0100 Subject: [PATCH 25/27] fix(dapp): size dollar amounts at a live price, and stop reading $5 of sol as 5 sol "swap $5 SOL to USDC" read as 5 SOL, and "offramp $1 worth of solana" was refused. A figure in dollars now carries amountIsUsd, must be in the person's own words, and becomes a count of tokens at the chain's price before any race, card or signature. With no price it is refused. USDC stays at face value. --- .../dapp/app/api/chain/[chain]/price/route.ts | 38 +++ apps/dapp/components/intents/intent-chat.tsx | 46 +++- .../lib/__tests__/chain-dollar-amount.test.ts | 239 ++++++++++++++++++ .../lib/__tests__/chain-price-route.test.ts | 51 ++++ .../dapp/lib/__tests__/chain-sequence.test.ts | 4 +- apps/dapp/lib/chains/dollar-amount.ts | 74 ++++++ apps/dapp/lib/chains/parse-action.ts | 26 +- apps/dapp/lib/chains/parse-sequence.ts | 28 +- apps/dapp/lib/chains/sequence-flow.ts | 61 +++++ apps/dapp/lib/chains/types.ts | 5 + apps/dapp/lib/weave/parse-cashout.ts | 21 +- 11 files changed, 569 insertions(+), 24 deletions(-) create mode 100644 apps/dapp/app/api/chain/[chain]/price/route.ts create mode 100644 apps/dapp/lib/__tests__/chain-dollar-amount.test.ts create mode 100644 apps/dapp/lib/__tests__/chain-price-route.test.ts create mode 100644 apps/dapp/lib/chains/dollar-amount.ts 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/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 277d940b..ff490172 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -80,6 +80,7 @@ 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 { sizeInUnits } from '../../lib/chains/dollar-amount' import { chainHelp, helpForUnread } from '../../lib/chains/explain' import { racesFirst } from '../../lib/chains/parse-action' import { @@ -100,7 +101,7 @@ import { sequenceOutcome, stepOnItsOwn, readFollowOnRefusal, - sequenceWordsMatch, + cashoutSaid, } from '../../lib/chains/sequence-flow' import type { ChainActionRequest, ChainWithActions, WalletBalances } from '../../lib/chains/types' import { QUOTE_EXPIRED } from '../../lib/chains/signing-flow' @@ -1776,8 +1777,45 @@ export function IntentChat({ setChainRace({ request: request.swap, text }) } + /** + * "$5 of SOL" as a count of SOL at a price read now, and says so. Both ways an action starts come + * through here first, so a figure in dollars can't reach a race or a card 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) + if (out.line !== undefined) { + setSequenceNote(out.line) + recordLive('agent', out.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) @@ -1803,6 +1841,10 @@ export function IntentChat({ /** A swap and what follows it. Whatever could not finish is refused here, before step 1. */ function startChainSequence(request: ChainSequenceRequest, text: string): void { + if (request.swap.amountIsUsd === true) { + sizeThen(request.swap, text, (swap) => startChainSequence({ ...request, swap }, text)) + return + } const capabilities = chainCapabilities(slug) if (capabilities === undefined) return const { followOn } = request @@ -2318,7 +2360,7 @@ export function IntentChat({ if (writtenOut.kind !== 'pass') { 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' && !sequenceWordsMatch(writtenOut, said)) { + if (writtenOut.kind === 'sequence' && !cashoutSaid(writtenOut, said)) { startCashout({ kind: 'token', token: writtenOut.token }, shown) return } 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-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-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts index bb67304e..abfbe460 100644 --- a/apps/dapp/lib/__tests__/chain-sequence.test.ts +++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts @@ -197,7 +197,7 @@ describe('routeCashout with an amount', () => { 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' }) + expect(parseCashout('cash out $5 sol')).toEqual({ unsupportedToken: 'SOL', dollars: '5' }) }) it('says on NEAR only what is true there', () => { @@ -494,7 +494,7 @@ describe('sequenceFromReading', () => { expect(out).toHaveProperty('refusal') }) - it('refuses a dollar amount, a received amount and a cash-out not ending in USDC', () => { + 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' diff --git a/apps/dapp/lib/chains/dollar-amount.ts b/apps/dapp/lib/chains/dollar-amount.ts new file mode 100644 index 00000000..859b11a5 --- /dev/null +++ b/apps/dapp/lib/chains/dollar-amount.ts @@ -0,0 +1,74 @@ +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) + 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/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index 4da81b74..f7c9088a 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -56,6 +56,16 @@ export function amountOf(text: string): string { 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 } + : {} +} + /** * Reads a plain instruction into an action, or nothing. * @@ -66,38 +76,46 @@ export 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: chainTokenWord(swap[2] 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: chainTokenWord(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: chainTokenWord(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), - tokenIn: chainTokenWord(send[2] as string), + ...dollarsOn(text, tokenIn), + tokenIn, recipient: send[3] as string, } } diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index e4e4d6be..fc57e860 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -5,14 +5,14 @@ import { routeCashout, type CashoutRoute, } from '../weave/parse-cashout' -import { AMOUNT, amountOf, chainTokenWord, parseChainAction } from './parse-action' +import { AMOUNT, amountOf, chainTokenWord, dollarsOn, parseChainAction } from './parse-action' import { SEQUENCE_CASHOUT_CHAINS, followOnRefusal, type ChainSequenceRequest, type FollowOn, } from './sequence' -import { sequenceWordsMatch, swapOrderSaid } from './sequence-flow' +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})` @@ -72,11 +72,13 @@ export function parseChainSequence( 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), - tokenIn: chainTokenWord(match[2] as string), + ...dollarsOn(text, tokenIn), + tokenIn, tokenOut: chainTokenWord(match[3] as string), venue: undefined, }, @@ -230,17 +232,13 @@ function refuseUnsaid( /** 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.amountIsUsd === true || - read.amountOf === 'received' || - !/^\d+(?:\.\d+)?$/.test(amount) - ) { + 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, @@ -273,8 +271,18 @@ export function requestSaid( /** A cash-out follows, which takes USDC, so a swap that names no output is to USDC. */ usdcImplied = false ): boolean { - if (!sequenceWordsMatch({ amount: request.amount, token: request.tokenIn }, said)) return false 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) diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index 6172a5cb..2228e769 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -679,6 +679,7 @@ export function cashoutSequence( swap: { kind: 'swap', amount: route.amount, + ...(route.amountIsUsd === true ? { amountIsUsd: true as const } : {}), tokenIn: route.token, tokenOut: 'USDC', venue: undefined, @@ -793,6 +794,66 @@ export function sequenceWordsMatch( return tokenSaid(instruction.token, turns) && last?.value === canonicalNumber(instruction.amount) } +/** + * 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, or be a bare + * correction after it, so "$5 of USDC to SOL" is not $5 of SOL. A token received is named after it. + */ +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(' ') + const number = String.raw`(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)` + const dollars = new RegExp( + String.raw`\$${number}|(?:^|[^\w.,$₦£€])${number}\s*(?:usd|dollars?|bucks?)(?![\w+])`, + 'gi' + ) + const last = [...joined.matchAll(dollars)].at(-1) + if (last === undefined) return false + if (figureValue((last[1] ?? last[2]) as string) !== canonicalNumber(amount)) return false + + const start = last.index ?? 0 + const end = start + last[0].length + const after = joined.slice(end) + const plain = figuresSaid(symbol, [joined]).at(-1) + if (plain !== undefined && joined.length - plain.after.length > end) return false + + const follows = new RegExp( + String.raw`^\s*(?:(?:usd|dollars?)\s+)?(?:worth\s+)?(?:of\s+)?(?:my\s+|the\s+)?(?:${token})${WORD_END}`, + 'i' + ).exec(after) + const precedes = new RegExp( + String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})\s+worth\s+(?:of\s+)?\s*$`, + 'i' + ).test(joined.slice(0, start)) + if (follows !== null || precedes) { + const rest = follows === null ? after : after.slice(follows[0].length) + return tokenOut === undefined || tokenSaid(tokenOut, [rest], true) + } + const bare = + BARE_FIGURE.test(after) && + tokenSaid(tokenIn, [joined.slice(0, start)]) && + !tokenSaid(tokenIn, [after], true) + if (!bare) 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('|') diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index df05de48..ce50abee 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -14,6 +14,11 @@ export type ChainActionKind = 'swap' | 'supply' | 'stake' | 'send' 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 diff --git a/apps/dapp/lib/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index f0e0ca2f..aac48fc8 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -12,7 +12,7 @@ export interface CashoutRequest { export type CashoutRead = | CashoutRequest - | { unsupportedToken: string; amount?: string; currency?: CashoutCurrencyCode } + | { unsupportedToken: string; amount?: string; dollars?: string; currency?: CashoutCurrencyCode } | { unsupportedCurrency: string } export type CashoutRoute = @@ -22,7 +22,14 @@ export type CashoutRoute = | { 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; currency?: CashoutCurrencyCode } + | { + 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 = { @@ -218,7 +225,7 @@ export function parseCashout(text: string): CashoutRead | null { return { unsupportedCurrency: OTHER_FIAT[target] as string } } // A dollar figure is not an amount of this token. - const ofToken = amount !== undefined && !dollar ? { amount } : {} + const ofToken = amount === undefined ? {} : dollar ? { dollars: amount } : { amount } return { unsupportedToken: token.toUpperCase(), ...ofToken, ...withCurrency } } if (token === undefined && amount !== undefined && !dollar) return null @@ -274,16 +281,18 @@ export function routeCashout( } if ('unsupportedToken' in read) { if (chain === 'stellar' && !weaveOnly(read.currency)) return { kind: 'pass' } + const figure = read.amount ?? read.dollars // A leading zero may be a decimal comma, "0,500" read as 0500, and a swap would take it as 500. if ( - read.amount !== undefined && - !/^0\d/.test(read.amount) && + figure !== undefined && + !/^0\d/.test(figure) && (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) ) { return { kind: 'sequence', token: chainTokenWord(read.unsupportedToken), - amount: read.amount, + amount: figure, + ...(read.dollars !== undefined ? { amountIsUsd: true as const } : {}), ...(read.currency !== undefined ? { currency: read.currency } : {}), } } From 42fb856ce6e4179e09e0d56b1546750e298c2fed Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:01:25 +0100 Subject: [PATCH 26/27] fix(dapp): let no model choose between dollars and a count, and price at answer "swap $1 of sol, no make it 2" ran as 2 SOL, and "5 sol if above $100" let the model pick either figure. A bare figure now only corrects dollars already said, and R$ or CA$ are not dollars. A currency question left open is priced when answered, and a retyped dollar amount still gets the duplicate warning. --- apps/dapp/components/intents/intent-chat.tsx | 23 ++-- .../lib/__tests__/chain-dollar-words.test.ts | 97 +++++++++++++++++ .../lib/__tests__/chain-signing-flow.test.ts | 6 ++ apps/dapp/lib/chains/dollar-amount.ts | 2 + apps/dapp/lib/chains/sequence-flow.ts | 100 +++++++++++++----- apps/dapp/lib/chains/signing-flow.ts | 10 +- 6 files changed, 200 insertions(+), 38 deletions(-) create mode 100644 apps/dapp/lib/__tests__/chain-dollar-words.test.ts diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index ff490172..5fd2301e 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -1769,6 +1769,11 @@ export function IntentChat({ /** 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() @@ -1778,8 +1783,8 @@ export function IntentChat({ } /** - * "$5 of SOL" as a count of SOL at a price read now, and says so. Both ways an action starts come - * through here first, so a figure in dollars can't reach a race or a card as that many tokens. + * "$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, @@ -1803,9 +1808,11 @@ export function IntentChat({ return } go(out.request) - if (out.line !== undefined) { - setSequenceNote(out.line) - recordLive('agent', out.line) + 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) } }) } @@ -1841,10 +1848,6 @@ export function IntentChat({ /** A swap and what follows it. Whatever could not finish is refused here, before step 1. */ function startChainSequence(request: ChainSequenceRequest, text: string): void { - if (request.swap.amountIsUsd === true) { - sizeThen(request.swap, text, (swap) => startChainSequence({ ...request, swap }, text)) - return - } const capabilities = chainCapabilities(slug) if (capabilities === undefined) return const { followOn } = request @@ -2958,6 +2961,8 @@ export function IntentChat({ 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() 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-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/chains/dollar-amount.ts b/apps/dapp/lib/chains/dollar-amount.ts index 859b11a5..c822cde9 100644 --- a/apps/dapp/lib/chains/dollar-amount.ts +++ b/apps/dapp/lib/chains/dollar-amount.ts @@ -19,6 +19,8 @@ export function unitsForDollars( 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 } diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts index 2228e769..d86a485a 100644 --- a/apps/dapp/lib/chains/sequence-flow.ts +++ b/apps/dapp/lib/chains/sequence-flow.ts @@ -791,14 +791,67 @@ export function sequenceWordsMatch( ): boolean { const symbol = chainTokenWord(instruction.token) const last = figuresSaid(symbol, turns).at(-1) - return tokenSaid(instruction.token, turns) && last?.value === canonicalNumber(instruction.amount) + 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, or be a bare - * correction after it, so "$5 of USDC to SOL" is not $5 of SOL. A token received is named after it. + * 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, @@ -808,39 +861,30 @@ export function dollarsSaid( ): 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 dollars = new RegExp( - String.raw`\$${number}|(?:^|[^\w.,$₦£€])${number}\s*(?:usd|dollars?|bucks?)(?![\w+])`, - 'gi' - ) - const last = [...joined.matchAll(dollars)].at(-1) - if (last === undefined) return false - if (figureValue((last[1] ?? last[2]) as string) !== canonicalNumber(amount)) return false + 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 start = last.index ?? 0 - const end = start + last[0].length - const after = joined.slice(end) + const after = joined.slice(last.end) const plain = figuresSaid(symbol, [joined]).at(-1) - if (plain !== undefined && joined.length - plain.after.length > end) return false + if (plain !== undefined && joined.length - plain.after.length > last.end) return false - const follows = new RegExp( - String.raw`^\s*(?:(?:usd|dollars?)\s+)?(?:worth\s+)?(?:of\s+)?(?:my\s+|the\s+)?(?:${token})${WORD_END}`, - 'i' - ).exec(after) - const precedes = new RegExp( - String.raw`(?:^|[^A-Za-z0-9.+])(?:${token})\s+worth\s+(?:of\s+)?\s*$`, - 'i' - ).test(joined.slice(0, start)) - if (follows !== null || precedes) { + 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 bare = + const correction = + figures.slice(0, -1).some(named) && BARE_FIGURE.test(after) && - tokenSaid(tokenIn, [joined.slice(0, start)]) && + CORRECTING.test(joined.slice(0, last.start)) && !tokenSaid(tokenIn, [after], true) - if (!bare) return false + if (!correction) return false return tokenOut === undefined || tokenOutFollows(token, tokenOut, joined) } diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index 7b86cc2e..5786b161 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -263,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, @@ -274,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] } From 743389127790998f76ed24c0e922c332942fdb94 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:05:17 +0100 Subject: [PATCH 27/27] fix(dapp): say which agents didn't answer, and tell them a later step runs itself A race that lost agents read as if they never existed, and agents off Stellar told people a cash-out was a separate job, when step 2 follows the swap. --- .../components/intents/competition-panel.tsx | 13 ++++- .../lib/__tests__/agents-unanswered.test.ts | 54 +++++++++++++++++++ apps/dapp/lib/agents/brief.ts | 4 +- apps/dapp/lib/agents/chain-race.ts | 21 ++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 apps/dapp/lib/__tests__/agents-unanswered.test.ts 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/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/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 fdc79ad8..05709685 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -239,6 +239,27 @@ 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.'