From 4badef49391617d55ffc92ab3ebc877cf8e93b6e Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:00:13 +0100 Subject: [PATCH 01/24] docs: plan the agent race on every chain --- .../plans/2026-11-04-agents-on-every-chain.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-04-agents-on-every-chain.md diff --git a/docs/superpowers/plans/2026-11-04-agents-on-every-chain.md b/docs/superpowers/plans/2026-11-04-agents-on-every-chain.md new file mode 100644 index 00000000..2bd234b7 --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-agents-on-every-chain.md @@ -0,0 +1,196 @@ +# Agent Competition on Every Chain Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A swap, supply or stake typed on NEAR, Solana, Sui, Avalanche or Monad runs the same agent competition as Stellar, and the winning plan is signed through that chain's existing safety checks. + +**Architecture:** `/api/agents/compete` gets a second branch for non-Stellar chains. It takes a `ChainActionRequest` rather than a `ParsedIntent`, asks a per-chain `quoteChainRoutes()` for priced routes, and streams the same frames, so the panel is unchanged. A route's `quote` is `{ kind: 'chain', request, venueId }`. Review calls the existing `chainAction.prepare`, so `assertSafe`, signing and submit all apply. Avalanche and Monad gain a `ChainModule` with one venue, 1Click. + +**Tech Stack:** Next 15, TypeScript, vitest, viem and wagmi, NEAR Intents 1Click REST. + +**Spec:** None written. The requirement is the user's "agentic competition on all chain" (2026-11-04). The research is in this plan's Facts section. + +## Facts this plan rests on + +Live dry quotes, $5, 2026-11-04, on `https://1click.chaindefuser.com/v0/quote`: + +| Chain | USDC to native | Native to USDC | 1Click tokens | +| --------- | -------------------------------- | -------------- | ---------------- | +| NEAR | HTTP 201 | HTTP 201 | 39 | +| Solana | HTTP 201 | HTTP 201 | 22 | +| Sui | HTTP 201 | HTTP 201 | SUI, USDC | +| Avalanche | HTTP 201 | HTTP 201 | AVAX, USDC, USDT | +| Monad | HTTP 201 | HTTP 201 | MON, USDC, USDT0 | +| Stellar | HTTP 400 `Incorrect depositMode` | same | not used here | + +Routes each chain will have: + +| Chain | Swap | Supply | Stake | +| --------- | --------------- | ----------------------------------- | ---------------- | +| NEAR | 1Click | 1Click Earn | Meta Pool, rNEAR | +| Solana | Jupiter, 1Click | Jupiter Lend, Kamino, Lulo with key | none | +| Sui | Cetus, 1Click | NAVI | SpringSui | +| Avalanche | 1Click | none | none | +| Monad | 1Click | none | none | + +## Global Constraints + +- Stellar's competition, swap, sequence and limit paths do not change. Every new branch tests `chain !== 'stellar'`. +- No edits to `components/intents/naira-payout-card.tsx`, `bank-picker.tsx` or `hooks/use-naira-payout.ts`. +- Agents choose a route id only. They never supply an amount, a recipient or a token (`tool-schema.ts` already has none). +- Race quotes must be `dry: true`. A deposit address is created only when the user presses Review. One press, one address. +- Every signed payload goes through `prepareChainAction` and then `venue.assertSafe`. Chain routes must never reach `useSwapExecution`. +- One route still races, as the user asked, and the panel says "One route available on {chain}. The agents are judging whether it's worth taking." +- On non-Stellar chains `executionMode` is forced to `fill`, `thenAction` to `none`. `rest` and `split` are not offered. +- No new dependencies. Commit subjects lowercase, header at most 100 characters, no co-author lines, no attribution footer. +- Disk is nearly full (about 7 GB free): do not start a second dev server or `next build`. Use vitest and `tsc --noEmit`. +- Work on a new branch `feat/agents-every-chain`, created from `feat/multichain-wallet-gate` with `git switch -c`, which changes no files and leaves the user's dev server running. + +## Review Focus + +- A token the chain doesn't list, such as "swap 5 FOO to USDC" on Solana: no race, no agent calls, the existing refusal is shown. Tested in Task 1. +- Quotes that all fail, such as 1Click down: agents are not called and the user sees a sentence. Tested in Task 1. +- 66-character Sui addresses reaching the rate limiter: the account is kept, not dropped. Tested in Task 1. +- Decimals of 6, 9, 18 and 24: the winner is the route with the better dollar output. Tested in Task 2. +- A second Review press on a NEAR, Avalanche or Monad route: no second deposit address while the first is on screen. Tested in Task 4. + +--- + +### Task 1: Compete route takes a chain request + +**Files:** + +- Create: `apps/dapp/lib/agents/chain-routes.ts` +- Modify: `apps/dapp/app/api/agents/compete/route.ts` +- Modify: `apps/dapp/lib/chains/types.ts` +- Test: `apps/dapp/lib/__tests__/agents-chain-compete.test.ts` + +**Interfaces:** + +- Produces: `interface ChainRouteQuote { kind: 'chain'; request: ChainActionRequest; venueId: string; venueName: string; pay: { amount: string; symbol: string }; receive?: { amount: string; symbol: string }; apyPercent?: number; valueInUsd?: number; valueOutUsd?: number }` +- Produces: `quoteChainRoutes(chainApi: ChainModule, request: ChainActionRequest, account: string): Promise`, with ids `${venueId}-1`, calling each usable venue's new optional `ChainVenue.quote`. +- Produces: `ChainVenue.quote?: (request: ChainActionRequest, account: string) => Promise` and `VenueQuote = { receive?: { amount: string; symbol: string }; apyPercent?: number; valueOutUsd?: number }`. +- Produces: compete body field `chainAction?: ChainActionRequest`. + +- [ ] **Step 1: Write failing tests**, with `chainModule` mocked: `chain: 'near'` with `chainAction` streams `competition:started`, not `chain_unsupported`; a body without `chainAction` on `near` gets `chain_unsupported`; a Stellar body takes the old path; an unknown token ends in `competition:error` with no `brain.propose` call; all quotes failing ends in `competition:error` with no `brain.propose` call; `account` of 66 characters reaches `enforceRateLimit`. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/agents-chain-compete.test.ts` from `apps/dapp`. Expected: FAIL. +- [ ] **Step 3: Implement.** Replace `EXECUTING_CHAINS` with a check that `chain === 'stellar' || chainModule(chain) !== undefined`. Raise the account limit to 128. Add a branch after the roster check that builds `market.routes` from `quoteChainRoutes`, skips `parseIntent`, `resolveAsset`, `fetchOrderBookTop` and `ensureLiveAssets`, and builds a minimal `ParsedIntent` via a new `intentFromChainAction()` so `brain.propose` and logging keep their types. +- [ ] **Step 4: Run** the same command. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): let the agent race take a chain action`. + +### Task 2: Prompts and scoring tell the truth per chain + +**Files:** + +- Modify: `apps/dapp/lib/agents/brief.ts`, `apps/dapp/lib/agents/brains/openai-compatible.ts:309,354,366`, `apps/dapp/lib/agents/market-context.ts:145,208`, `apps/dapp/lib/agents/measure.ts` +- Modify: `apps/dapp/app/api/agents/compete/route.ts` (`measured`, `settlePlan`) +- Test: `apps/dapp/lib/__tests__/agents-chain-prompt.test.ts` + +**Interfaces:** + +- Consumes: `ChainRouteQuote` (Task 1). +- Produces: `systemPromptFor(chain: string): string`; `measureChainRoute(q: ChainRouteQuote): { outUsd: number; vsInPct: number } | undefined`; `settleChainPlan(p: AgentProposalResult): AgentProposalResult`. + +- [ ] **Step 1: Write failing tests:** the prompt for `near` contains neither "testnet", "anchor", "rest" nor "split"; for `stellar` it equals today's `SYSTEM_PROMPT`; `measureChainRoute` ranks $4.97 out above $4.74 out from quotes in 6, 9, 18 and 24 decimals; a proposal with `executionMode: 'rest'` and `thenAction: 'lend'` becomes `fill` and `none`; `buildMarketContext('solana')` offers only venues with `integration: 'executes'`, and no Stellar tokens. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/agents-chain-prompt.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Supply and stake routes rank on `apyPercent`, using each venue's existing rate reader (`jupiter-lend.ts:140`, `kamino.ts:109`, `near/stake.ts:53`). Prices come from `chainApi.priceUsd`. +- [ ] **Step 4: Run** the same command. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): give agents each chain's own facts`. + +### Task 3: NEAR races (hackathon, ships first) + +**Files:** + +- Modify: `apps/dapp/lib/chains/near/swap.ts`, `apps/dapp/lib/chains/near/stake.ts`, `apps/dapp/lib/chains/near/earn.ts` +- Test: `apps/dapp/lib/__tests__/near-race-quote.test.ts` + +**Interfaces:** + +- Consumes: `ChainVenue.quote` (Task 1). +- Produces: `quote` on `oneClickVenue`, `metapoolVenue`, `rnearVenue`, `earnVenue`. + +- [ ] **Step 1: Write failing tests:** `oneClickVenue.quote` posts `dry: true` and returns `receive` and `valueOutUsd`; it runs `assertQuoteMatches`, reusing that check; it never reads `depositAddress`; each stake venue returns its `apyPercent`. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/near-race-quote.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement** by reusing `requestQuote` and the preview half of `prepareOneClickDeposit`. +- [ ] **Step 4: Run** the same command. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): quote near venues for the agent race`. + +### Task 4: The chat runs the race and Review signs the winner + +**Files:** + +- Modify: `apps/dapp/hooks/use-competition.ts`, `apps/dapp/components/intents/intent-chat.tsx` (the `parseChainAction` branch, `handleExecute`, `locked`), `apps/dapp/components/intents/competition-panel.tsx`, `apps/dapp/lib/orchestrate/capabilities.ts:11` +- Create: `apps/dapp/lib/agents/chain-race.ts` +- Test: `apps/dapp/lib/__tests__/agents-chain-race.test.ts` + +**Interfaces:** + +- Consumes: compete `chainAction` (Task 1), `ChainRouteQuote` (Task 1). +- Produces: `chainRequestFor(route: unknown, asked: ChainActionRequest): ChainActionRequest | undefined`, which returns the request with `venue` set and refuses anything whose token or amount differs from `asked`. +- Produces: `useCompetition` option `chainAction`, sent in place of `parsed`. + +- [ ] **Step 1: Write failing tests:** `chainRequestFor` sets `venue`; it returns undefined when a route's amount, `tokenIn` or `tokenOut` differs from `asked`; it returns undefined for a Stellar `SwapQuote`; `singleRouteNote(1, 'NEAR')` returns the Global Constraints sentence; `singleRouteNote(2, 'NEAR')` returns undefined; a second call while one prepare is pending does not call `prepare` again. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/agents-chain-race.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** In the chat, `parseChainAction` success starts the race rather than `chainAction.prepare`. `handleExecute` on non-Stellar calls `chainAction.prepare(chainRequestFor(route, asked), text)`. If the race ends in an error frame, call `chainAction.prepare(asked, text)` so the chain keeps working when agents are offline. Include `chainAction.phase` signing and submitting in `locked`. +- [ ] **Step 4: Run** the same command and `pnpm exec tsc --noEmit`. Expected: PASS, exit 0. +- [ ] **Step 5: Commit** `feat(dapp): run the agent race on near from chat`. + +### Task 5: Solana and Sui race, with 1Click as a second route + +**Files:** + +- Create: `apps/dapp/lib/chains/oneclick-origin.ts` +- Modify: `apps/dapp/lib/chains/near/oneclick.ts` (`assetIdOf` by chain), `apps/dapp/lib/chains/solana/jupiter-swap.ts`, `solana/jupiter-lend.ts`, `solana/kamino.ts`, `solana/lulo.ts`, `solana/module.ts`, `sui/cetus.ts`, `sui/navi.ts`, `sui/springsui.ts`, `sui/module.ts` +- Test: `apps/dapp/lib/__tests__/oneclick-origin.test.ts`, `apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts` + +**Interfaces:** + +- Consumes: `solanaDeposit` and `suiDeposit` builders and asserters in `lib/weave/deposit/`. +- Produces: `oneClickOriginVenue(chain: 'solana' | 'sui' | 'avalanche' | 'monad'): ChainVenue`, whose `prepare` takes a `dry: false` quote and builds one transfer to its deposit address, whose `assertSafe` admits only that transfer, and whose `submit` confirms it and follows `depositStatus`. +- Produces: `oneClickAssetId(chain, token): Promise` from `listTokens()`, matched on chain and contract, refusing a symbol match alone. + +- [ ] **Step 1: Write failing tests:** the Jupiter `quote` uses `/swap/v2/order` and returns `receive` without exposing `transaction`; the Cetus `quote` returns its best route; `oneClickAssetId` refuses a look-alike symbol on another contract; `assertSafe` refuses a wrong recipient, wrong amount and an extra transfer; a deposit memo is refused; a native SOL or SUI input is refused with a plain sentence, so only USDC and USDT reach 1Click in this task. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/oneclick-origin.test.ts lib/__tests__/solana-sui-race-quote.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Generalise the deposit builders by passing the token rather than assuming USDC, keeping their tests green. Add the venue to `venues` in `solana/module.ts` and `sui/module.ts`. Add its row and logo per `feedback-always-add-venue-logos`, which `venue-logos.test.ts` enforces. +- [ ] **Step 4: Run** the same command plus `pnpm exec vitest run lib/__tests__/weave-deposit`. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): race jupiter and cetus against 1click`. + +### Task 6: Avalanche and Monad can swap, and race + +**Files:** + +- Create: `apps/dapp/lib/chains/evm/module.ts`, `apps/dapp/lib/chains/evm/tokens.ts`, `apps/dapp/lib/chains/evm/submit.ts` +- Modify: `apps/dapp/lib/chains/types.ts` (`ChainWithActions`, evm `value`), `apps/dapp/lib/chains/registry.ts`, `apps/dapp/lib/chains/capabilities.ts`, `apps/dapp/lib/adapters/evm-adapter.ts` (Monad `signPrepared`, non-zero `value`), `apps/dapp/app/api/chain/[chain]/submit/route.ts` (`evm` in the union, family check), `apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts:512` +- Test: `apps/dapp/lib/__tests__/evm-module.test.ts` + +**Interfaces:** + +- Consumes: `oneClickOriginVenue` (Task 5). +- Produces: `avalancheModule`, `monadModule: ChainModule`, with tokens AVAX, USDC, USDT on Avalanche and MON, USDC, USDT0 on Monad, contract addresses read from 1Click's list and checked against `0xb97ef9ef8734...` for Avalanche USDC and `0x754704bc059f...` for Monad USDC. + +- [ ] **Step 1: Write failing tests:** `chainModule('monad')` and `chainModule('avalanche')` are defined; a native AVAX transfer payload passes `assertSafe` only with `value` equal to the quote's amount and empty `data`; an ERC-20 payload passes only with `value: '0'`; a Monad `chainId` of 143 is required, and 43114 is refused on Monad; the submit route accepts `{ family: 'evm', hash }` for both; a receipt from another account is refused. +- [ ] **Step 2: Run** `pnpm exec vitest run lib/__tests__/evm-module.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Confirm Monad's chain id with `viem/chains` before writing it. Reuse the Avalanche deposit's receipt check for ERC-20 and write the equivalent for native. +- [ ] **Step 4: Run** `pnpm exec vitest run lib/__tests__/evm-module.test.ts lib/__tests__/weave-deposit-avalanche.test.ts`. Expected: PASS. +- [ ] **Step 5: Commit** `feat(dapp): swap and race on avalanche and monad through 1click`. + +### Task 7: Record which chain each race ran on + +**Files:** + +- Modify: `apps/dapp/lib/server/analytics.ts:65`, `apps/dapp/app/api/agents/history/route.ts`, `apps/dapp/app/api/chain/[chain]/submit/route.ts:73-80` +- Create: `packages/db/migrations/014_agent_races_chain.sql` +- Test: extend `apps/dapp/lib/__tests__/agents-chain-compete.test.ts` + +- [ ] **Step 1: Write failing tests:** `logRace` writes `chain`; the history route accepts a Solana, Sui, NEAR and EVM account rather than only `StrKey`; a chain submit carries the race id it came from. +- [ ] **Step 2: Run** the test file. Expected: FAIL. +- [ ] **Step 3: Implement,** with `ALTER TABLE agent_races ADD COLUMN IF NOT EXISTS chain TEXT NOT NULL DEFAULT 'stellar'` in both the migration and the code's DDL. +- [ ] **Step 4: Verify the whole change.** Run `pnpm exec tsc --noEmit`, `pnpm exec next lint --quiet` and `pnpm exec vitest run --maxWorkers=4 --minWorkers=1`. Expected: exit 0 for the first two. The only failures allowed are live-network tests in code this plan doesn't touch, and each must be named in the report. +- [ ] **Step 5: Commit** `feat(dapp): record the chain of every agent race`. + +## Not in this plan + +- Agents choosing limit orders, swap-then-deposit, or cash-out on these chains. They need those features to exist first. +- A second swap venue on NEAR (Rhea needs `ft_transfer_call`, which `near/safety.ts:10` refuses), Avalanche or Monad (neither is covered by the integration report), so these three start with one swap route. +- Native SOL and SUI through 1Click. Jupiter and Cetus still take them. +- A real signed transaction: the user does that with a funded wallet. No agent in this plan may sign or send. From 371f97473214e11ef34465019aced9ef9e4afcfc Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:05:33 +0100 Subject: [PATCH 02/24] feat(dapp): let the agent race take a chain action --- apps/dapp/app/api/agents/compete/route.ts | 187 ++++++++++++++---- .../__tests__/agents-chain-compete.test.ts | 156 +++++++++++++++ .../lib/__tests__/compete-route-log.test.ts | 2 +- apps/dapp/lib/agents/chain-routes.ts | 97 +++++++++ apps/dapp/lib/chains/prepare.ts | 35 ++-- apps/dapp/lib/chains/types.ts | 13 ++ 6 files changed, 441 insertions(+), 49 deletions(-) create mode 100644 apps/dapp/lib/__tests__/agents-chain-compete.test.ts create mode 100644 apps/dapp/lib/agents/chain-routes.ts diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 57bf8348..6998194d 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -2,7 +2,8 @@ import { activeNetwork } from '@intent/config' import { NextResponse } from 'next/server' import { randomUUID } from 'node:crypto' -import type { AgentProposalResult } from '../../../../lib/agents/brain' +import type { AgentProposalResult, MarketContext } from '../../../../lib/agents/brain' +import { intentFromChainAction, quoteChainRoutes } from '../../../../lib/agents/chain-routes' import type { CompetitionFrame } from '../../../../lib/agents/events' import { encodeFrame } from '../../../../lib/agents/events' import { buildMarketContextAsync, quoteRoutes } from '../../../../lib/agents/market-context' @@ -11,6 +12,14 @@ import { benchedUntilFor, lineUp, noteRateLimited } from '../../../../lib/agents import { getRoster } from '../../../../lib/agents/registry' import type { PlanContext } from '../../../../lib/agents/plan-scoring' import { pickWinner, scoreProposals, unanimousChoice } from '../../../../lib/agents/scoring' +import { assertKnownTokens } from '../../../../lib/chains/prepare' +import { chainModule } from '../../../../lib/chains/registry' +import { + ChainActionRefused, + type ChainActionKind, + type ChainActionRequest, + type ChainModule, +} from '../../../../lib/chains/types' import { parseCompoundIntent } from '../../../../lib/parse-compound' import { isLimitType } from '../../../../lib/intent-kind' import { buildParsedIntent, parseIntent, type ReadingForIntent } from '../../../../lib/parse-intent' @@ -57,7 +66,7 @@ const AGENT_TIMEOUT_MS = 60_000 const WINDOW_SECONDS = 30 /** - * Chains a competition can execute on. + * Chains a competition can execute on: Stellar, and any chain with a module. * * Checked before anything runs, and by name. The chain used to default to * 'arc' when the body carried none, and an unrecognised slug fell through to @@ -65,8 +74,90 @@ const WINDOW_SECONDS = 30 * which the agents were offered Uniswap. Now a missing or unsupported chain is * an answer the user reads, not a race that quietly runs on the wrong facts. */ -const EXECUTING_CHAINS: Record = { - stellar: 'Stellar', +function canExecuteOn(chain: string): boolean { + return chain === 'stellar' || chainModule(chain) !== undefined +} + +const CHAIN_ACTION_KINDS: readonly ChainActionKind[] = ['swap', 'supply', 'stake'] + +/** The longest account a chain uses is a 64-character NEAR implicit account; Sui and EVM are shorter. */ +const MAX_ACCOUNT_CHARS = 128 + +/** + * Reads the structured action from the body, or nothing. Only the shape is + * checked here; whether the tokens are real is checked against the chain. + */ +function chainActionFromBody(raw: unknown): ChainActionRequest | undefined { + if (typeof raw !== 'object' || raw === null) return undefined + const { kind, amount, tokenIn, tokenOut, venue } = raw as Record + if (typeof kind !== 'string' || !CHAIN_ACTION_KINDS.includes(kind as ChainActionKind)) { + return undefined + } + if (typeof amount !== 'string' || typeof tokenIn !== 'string') return undefined + return { + kind: kind as ChainActionKind, + amount: amount.slice(0, 40), + tokenIn: tokenIn.slice(0, 40), + ...(typeof tokenOut === 'string' ? { tokenOut: tokenOut.slice(0, 40) } : {}), + ...(typeof venue === 'string' ? { venue: venue.slice(0, 40) } : {}), + } +} + +/** + * What the agents are given for an action on a chain that is not Stellar: the + * chain's own venues and real quotes, and none of Stellar's prices, assets or + * order book. Returns the sentence to show instead when there is nothing to race. + */ +async function chainRaceFacts( + chain: string, + chainApi: ChainModule, + action: ChainActionRequest, + account: string | undefined, + text: string +): Promise<{ intent: ParsedIntent; market: MarketContext } | string> { + try { + assertKnownTokens(chainApi, action) + } catch (e) { + if (e instanceof ChainActionRefused) return `${e.message} Nothing was signed or sent.` + throw e + } + if (account === undefined) { + return 'No wallet account came with this request, so nothing could be priced. Nothing was signed or sent.' + } + + const routes = await quoteChainRoutes(chainApi, action, account) + if (routes.length === 0) { + return 'No route on this chain could be priced right now, so the agents had nothing to judge. Nothing was signed or sent.' + } + + const tokenIn = chainApi.tokens.find( + (t) => t.symbol.toUpperCase() === action.tokenIn.toUpperCase() + ) + const price = + tokenIn === undefined ? undefined : await chainApi.priceUsd(tokenIn).catch(() => undefined) + + // Stellar's assets, lending rates and anchors are left out: they describe a + // different chain, and an agent shown them would reason about venues that do + // not exist here. + const { + assets: _assets, + lending: _lending, + offramps: _offramps, + ...base + } = await buildMarketContextAsync(chain) + const market: MarketContext = { + ...base, + prices: price !== undefined && tokenIn !== undefined ? { [tokenIn.symbol]: price } : {}, + venues: chainApi.venues + .filter((v) => routes.some((r) => r.id === `${v.id}-1`)) + .map((v) => ({ + id: v.id, + name: v.name, + category: action.kind === 'swap' ? 'dex' : action.kind === 'supply' ? 'lending' : 'staking', + })), + routes, + } + return { intent: intentFromChainAction(action, text, price), market } } /** @@ -167,7 +258,7 @@ export async function POST(request: Request): Promise { b !== null && 'account' in b && typeof b.account === 'string' && - b.account.length <= 64 + b.account.length <= MAX_ACCOUNT_CHARS ? b.account : undefined ) @@ -180,6 +271,7 @@ export async function POST(request: Request): Promise { let chain: string let context: string[] let reading: ReadingForIntent | undefined + let chainAction: ChainActionRequest | undefined try { const body = (await request.json()) as { @@ -187,13 +279,17 @@ export async function POST(request: Request): Promise { chain?: unknown context?: unknown reading?: unknown + chainAction?: unknown } if (typeof body.text !== 'string' || body.text.trim() === '') { return NextResponse.json({ error: 'invalid_text' }, { status: 400 }) } text = body.text.trim().slice(0, MAX_INTENT_CHARS) - await ensureLiveAssets(tickersIn(text)) chain = typeof body.chain === 'string' ? body.chain.trim() : '' + chainAction = chainActionFromBody(body.chainAction) + // Stellar's live-asset lookup is for Stellar's tickers; a chain action + // names its tokens itself and they are checked against the chain's own list. + if (chain === 'stellar' || chainAction === undefined) await ensureLiveAssets(tickersIn(text)) reading = readingFromBody(body.reading, text) context = Array.isArray(body.context) ? body.context.filter((line): line is string => typeof line === 'string').slice(0, 8) @@ -211,11 +307,17 @@ export async function POST(request: Request): Promise { 'No chain was named for this intent. Reload the app and try again.' ) } - if (EXECUTING_CHAINS[chain] === undefined) { + if (!canExecuteOn(chain)) { return errorStream( 'chain_unsupported', - `Agents can execute only on ${Object.values(EXECUTING_CHAINS).join(', ')} right now. ` + - `Switch the chain to run a competition.` + 'Agents can execute only on Stellar, Solana, Sui and NEAR right now. ' + + 'Switch the chain to run a competition.' + ) + } + if (chain !== 'stellar' && chainAction === undefined) { + return errorStream( + 'chain_unsupported', + 'This request did not say what to do on this chain, so no race was run. Nothing was signed or sent.' ) } @@ -236,45 +338,57 @@ export async function POST(request: Request): Promise { ) } + // Off Stellar the facts come from the chain's own venues and quotes, and a + // refusal or an unpriceable action ends here, before any agent is called. + const chainApi = chain === 'stellar' ? undefined : chainModule(chain) + const chainFacts = + chainApi !== undefined && chainAction !== undefined + ? await chainRaceFacts(chain, chainApi, chainAction, account, text) + : undefined + if (typeof chainFacts === 'string') return errorStream('chain_unsupported', chainFacts) + const competitionId = randomUUID() // Taken here, in the request, so the race is recorded for the network it ran on. const network = activeNetwork() const startedAt = new Date() // Prices are fetched first so the parser can size "$30 of XLM" against the // real market rather than an indicative table. - const market = await buildMarketContextAsync(chain) + const market = chainFacts?.market ?? (await buildMarketContextAsync(chain)) // A structured reading, when the chat has one, is what the agents are given. // Re-reading the words would lose a paraphrase that no keyword recognises. const intent = - reading !== undefined + chainFacts?.intent ?? + (reading !== undefined ? buildParsedIntent(reading, market.prices) - : parseIntent(text, market.prices) + : parseIntent(text, market.prices)) // Priced before the agents run, so they choose between real routes rather // than describing hypothetical ones. Failing to quote is not fatal: the // competition proceeds without executable routes and says so. - try { - const routes = await quoteRoutes( - chain, - intent.input.tokenIn, - intent.input.tokenOut, - toBaseUnits(intent.input.amountIn), - undefined, - // A limit order states a price it will not trade through, expressed as - // a minimum output so the quoter can decline. Keyed on `limitPriceUsd` - // rather than `targetPriceUsd`: the latter falls back to spot when the - // user named no price, and a limit derived from the market is not one. - isLimitType(intent.input.type) && intent.limitPriceUsd !== undefined - ? { - minReceive: toBaseUnits( - (Number(intent.input.amountIn) * intent.limitPriceUsd).toFixed(7) - ), - } - : {} - ) - if (routes.length > 0) market.routes = routes - } catch { - // Leaving routes unset is the honest outcome; agents reason without them. + if (chainFacts === undefined) { + try { + const routes = await quoteRoutes( + chain, + intent.input.tokenIn, + intent.input.tokenOut, + toBaseUnits(intent.input.amountIn), + undefined, + // A limit order states a price it will not trade through, expressed as + // a minimum output so the quoter can decline. Keyed on `limitPriceUsd` + // rather than `targetPriceUsd`: the latter falls back to spot when the + // user named no price, and a limit derived from the market is not one. + isLimitType(intent.input.type) && intent.limitPriceUsd !== undefined + ? { + minReceive: toBaseUnits( + (Number(intent.input.amountIn) * intent.limitPriceUsd).toFixed(7) + ), + } + : {} + ) + if (routes.length > 0) market.routes = routes + } catch { + // Leaving routes unset is the honest outcome; agents reason without them. + } } // One read for the whole competition: every agent's resting price is checked @@ -282,7 +396,7 @@ export async function POST(request: Request): Promise { let book: OrderBookTop | undefined const from = resolveAsset(intent.input.tokenIn) const to = resolveAsset(intent.input.tokenOut) - if (from !== undefined && to !== undefined) { + if (chainFacts === undefined && from !== undefined && to !== undefined) { const base = from.issuer === undefined ? from : to const counter = from.issuer === undefined ? to : from try { @@ -430,7 +544,8 @@ export async function POST(request: Request): Promise { // What the user actually asked for, so a follow-on nobody requested // can be told apart from one they did. The same parser the sequence // flow uses, rather than a second opinion about the same sentence. - const compound = parseCompoundIntent(text, market.prices) + const compound = + chainFacts === undefined ? parseCompoundIntent(text, market.prices) : undefined const followOn = compound?.followOn.kind const anchorLimits = (market.offramps ?? []).find((o) => o.asset === 'USDC') diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts new file mode 100644 index 00000000..14b6798c --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * The race on a chain other than Stellar. The chain module, the roster and the + * market are faked; under test is that a chain action starts a race, that a + * refusal or an unpriceable action never reaches an agent, and that Stellar + * keeps its old path. + */ + +const state = vi.hoisted(() => ({ + roster: undefined as unknown, + module: undefined as unknown, + propose: undefined as unknown as ReturnType, +})) + +vi.mock('../server/analytics', () => ({ logRace: async () => undefined })) +const enforceRateLimit = vi.fn(async () => undefined) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit })) +vi.mock('../agents/registry', () => ({ getRoster: () => state.roster })) +vi.mock('../chains/registry', () => ({ + chainModule: (chain: string) => (chain === 'near' ? state.module : undefined), +})) +vi.mock('../agents/market-context', () => ({ + buildMarketContextAsync: async () => ({ + prices: { XLM: 0.1, USDC: 1 }, + venues: [], + routes: [], + }), + quoteRoutes: async () => [], +})) +vi.mock('../swap/limit-price', () => ({ fetchOrderBookTop: async () => undefined })) + +const action = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } + +function nearModule(quote: unknown) { + return { + chain: 'near', + tokens: [ + { symbol: 'NEAR', id: 'wrap.near', decimals: 24 }, + { symbol: 'USDC', id: 'usdc.near', decimals: 6 }, + ], + venues: [{ id: 'ref', name: 'Ref', kinds: ['swap'], quote }], + defaultVenue: {}, + priceUsd: async () => 4, + } +} + +const post = (body: unknown): Request => + new Request('http://localhost/api/agents/compete', { + method: 'POST', + body: JSON.stringify(body), + }) + +async function frames(res: Response): Promise[]> { + const text = await res.text() + return text + .split('\n\n') + .map((chunk) => chunk.replace(/^data: /, '').trim()) + .filter((chunk) => chunk.startsWith('{')) + .map((chunk) => JSON.parse(chunk)) +} + +beforeEach(() => { + state.propose = vi.fn(async () => ({ ok: false, error: 'boom' })) + state.roster = [ + { + key: 'halcyon', + name: 'Halcyon', + gradient: 'a', + model: 'm', + brain: { propose: state.propose }, + }, + ] + state.module = nearModule(async () => ({ receive: { amount: '19.9', symbol: 'USDC' } })) + enforceRateLimit.mockClear() +}) + +afterEach(() => { + vi.resetModules() +}) + +describe('POST /api/agents/compete on a chain', () => { + it('starts a race for a chain action on near', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ text: 'swap 5 NEAR to USDC', chain: 'near', account: 'a.near', chainAction: action }) + ) + ) + expect(out[0]?.type).toBe('competition:started') + expect(state.propose).toHaveBeenCalledTimes(1) + const given = (state.propose as any).mock.calls[0][0] + expect(given.market.routes).toHaveLength(1) + expect(given.market.routes[0]).toMatchObject({ id: 'ref-1', source: 'Ref' }) + expect(given.market.venues).toEqual([{ id: 'ref', name: 'Ref', category: 'dex' }]) + }) + + it('still refuses near without a chain action', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames(await POST(post({ text: 'swap 5 NEAR to USDC', chain: 'near' }))) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'chain_unsupported' }) + expect(state.propose).not.toHaveBeenCalled() + }) + + it('keeps the old path for stellar', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar', chainAction: action })) + ) + expect(out[0]?.type).toBe('competition:started') + const given = (state.propose as any).mock.calls[0][0] + expect(given.intent.input).toMatchObject({ tokenIn: 'XLM', tokenOut: 'USDC' }) + }) + + it('refuses an unknown token without calling an agent', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'swap 5 NEAR to SCAM', + chain: 'near', + account: 'a.near', + chainAction: { ...action, tokenOut: 'SCAM' }, + }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]?.type).toBe('competition:error') + expect(out[0]?.message).toContain("I can't confirm SCAM as a verified token") + expect(state.propose).not.toHaveBeenCalled() + }) + + it('ends in an error, calling no agent, when every quote fails', async () => { + state.module = nearModule(async () => { + throw new Error('down') + }) + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ text: 'swap 5 NEAR to USDC', chain: 'near', account: 'a.near', chainAction: action }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ type: 'competition:error' }) + expect(out[0]?.message).toContain('Nothing was signed or sent') + expect(state.propose).not.toHaveBeenCalled() + }) + + it('rate-limits by an account of 66 characters', async () => { + const account = 'a'.repeat(66) + const { POST } = await import('../../app/api/agents/compete/route') + await (await POST(post({ text: 'swap', chain: 'near', account }))).text() + expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', account) + }) +}) diff --git a/apps/dapp/lib/__tests__/compete-route-log.test.ts b/apps/dapp/lib/__tests__/compete-route-log.test.ts index 6951eb0d..4ed6a9f4 100644 --- a/apps/dapp/lib/__tests__/compete-route-log.test.ts +++ b/apps/dapp/lib/__tests__/compete-route-log.test.ts @@ -167,7 +167,7 @@ describe('POST /api/agents/compete', () => { enforceRateLimit.mockClear() const { POST } = await import('../../app/api/agents/compete/route') await ( - await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar', account: 'x'.repeat(65) })) + await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar', account: 'x'.repeat(129) })) ).text() expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', undefined) diff --git a/apps/dapp/lib/agents/chain-routes.ts b/apps/dapp/lib/agents/chain-routes.ts new file mode 100644 index 00000000..ecb62412 --- /dev/null +++ b/apps/dapp/lib/agents/chain-routes.ts @@ -0,0 +1,97 @@ +import type { ChainActionRequest, ChainModule } from '../chains/types' +import type { ParsedIntent } from '../parse-intent' +import type { QuotedRoute } from './brain' + +/** What a chain route carries as its `quote`, so the winner can be prepared for signing. */ +export interface ChainRouteQuote { + kind: 'chain' + request: ChainActionRequest + venueId: string + venueName: string + pay: { amount: string; symbol: string } + receive?: { amount: string; symbol: string } + apyPercent?: number + valueInUsd?: number + valueOutUsd?: number +} + +/** + * Prices the action at every venue that can do it and has a quote to give. + * + * A venue that errors is left out rather than failing the race: the agents + * choose between the prices that exist. A venue with no `quote` is skipped + * because it has no honest figure to offer, and a made-up one would be judged + * as if it were real. + */ +export async function quoteChainRoutes( + chainApi: ChainModule, + request: ChainActionRequest, + account: string +): Promise { + const named = request.venue?.trim().toLowerCase() + const usable = chainApi.venues.filter( + (v) => + v.kinds.includes(request.kind) && + v.quote !== undefined && + (named === undefined || v.id.toLowerCase() === named || v.name.toLowerCase().includes(named)) + ) + + const settled = await Promise.all( + usable.map(async (venue): Promise => { + try { + const quoted = await venue.quote?.(request, account) + if (quoted === undefined) return undefined + const quote: ChainRouteQuote = { + kind: 'chain', + request, + venueId: venue.id, + venueName: venue.name, + pay: { amount: request.amount, symbol: request.tokenIn }, + ...(quoted.receive !== undefined ? { receive: quoted.receive } : {}), + ...(quoted.apyPercent !== undefined ? { apyPercent: quoted.apyPercent } : {}), + ...(quoted.valueOutUsd !== undefined ? { valueOutUsd: quoted.valueOutUsd } : {}), + } + return { + id: `${venue.id}-1`, + source: venue.name, + sendAmount: request.amount, + receiveAmount: quoted.receive?.amount ?? '', + hops: 1, + executable: true, + quote, + } + } catch { + return undefined + } + }) + ) + return settled.filter((r): r is QuotedRoute => r !== undefined) +} + +/** + * The least a `ParsedIntent` needs so the brain and the race log keep their + * types. Built from the structured request, never by reading words again: the + * chain, token and amount were already settled by the chat. + */ +export function intentFromChainAction( + request: ChainActionRequest, + text: string, + priceUsd: number | undefined +): ParsedIntent { + const tokenOut = request.kind === 'swap' ? (request.tokenOut ?? request.tokenIn) : request.tokenIn + const escrowUsd = priceUsd === undefined ? 0 : Math.round(Number(request.amount) * priceUsd) + return { + outcome: text, + escrowUsd, + referencePriceUsd: priceUsd ?? 0, + targetPriceUsd: priceUsd ?? 0, + input: { + type: 'market_sell', + tokenIn: request.tokenIn, + tokenOut, + amountIn: request.amount, + minAmountOut: '0', + deadline: new Date(Date.now() + 30 * 60_000).toISOString(), + }, + } +} diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index a30eac8e..1fc3477e 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -4,6 +4,7 @@ import { tokenBySymbol, type ChainActionRequest, type ChainModule, + type ChainToken, type ChainVenue, type PreparedAction, } from './types' @@ -34,19 +35,11 @@ function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVen } /** - * Prepares an action, with the same order of checks as every other money route: - * known tokens, a dollar value under the cap, then the build, then a check of - * what the venue's API returned before anyone is asked to sign it. - * - * The cap always reads as mainnet, because these chains only run on mainnet. The - * Stellar network a deployment defaults to says nothing about Solana's funds. + * Refuses a token this chain does not list as verified, in the same words + * wherever it is asked: the signing route and the agent race both call this, so + * a refused token reads the same in each and no agent is ever consulted about it. */ -export async function prepareChainAction( - chainApi: ChainModule, - request: ChainActionRequest, - account: string, - env: Record = process.env -): Promise { +export function assertKnownTokens(chainApi: ChainModule, request: ChainActionRequest): ChainToken { const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn) if (tokenIn === undefined) { throw new ChainActionRefused( @@ -66,6 +59,24 @@ export async function prepareChainAction( throw new ChainActionRefused('Those are the same token.') } } + return tokenIn +} + +/** + * Prepares an action, with the same order of checks as every other money route: + * known tokens, a dollar value under the cap, then the build, then a check of + * what the venue's API returned before anyone is asked to sign it. + * + * The cap always reads as mainnet, because these chains only run on mainnet. The + * Stellar network a deployment defaults to says nothing about Solana's funds. + */ +export async function prepareChainAction( + chainApi: ChainModule, + request: ChainActionRequest, + account: string, + env: Record = process.env +): Promise { + const tokenIn = assertKnownTokens(chainApi, request) const price = await chainApi.priceUsd(tokenIn) const amount = Number(request.amount) diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 26f8d782..21ca5533 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -96,10 +96,23 @@ export interface ChainToken { /** Raised for a refusal the user should read, such as an unknown token or thin liquidity. */ export class ChainActionRefused extends Error {} +/** What a venue says it would give back, read without building anything to sign. */ +export interface VenueQuote { + receive?: { amount: string; symbol: string } + apyPercent?: number + valueOutUsd?: number +} + export interface ChainVenue { id: string name: string kinds: readonly ChainActionKind[] + /** + * A price for the agent race. Must not create a deposit address or any other + * thing that costs money or leaves state behind: a race quotes every venue, + * and only the one the user presses Review on may be built. + */ + quote?: ((request: ChainActionRequest, account: string) => Promise) | undefined prepare: (request: ChainActionRequest, account: string) => Promise /** * Refuses a payload that does anything but what was asked: wrong signer, an From d0bd890505b7d99a05f7455768043ea5539efc12 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:08:50 +0100 Subject: [PATCH 03/24] fix(dapp): refuse a bad race amount before quoting --- apps/dapp/app/api/agents/compete/route.ts | 5 +-- .../__tests__/agents-chain-compete.test.ts | 34 +++++++++++++++++-- apps/dapp/lib/chains/prepare.ts | 33 +++++++++++++----- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 6998194d..07aafbdc 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -12,7 +12,7 @@ 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 { assertKnownTokens } from '../../../../lib/chains/prepare' +import { assertAmountWithinCap, assertKnownTokens } from '../../../../lib/chains/prepare' import { chainModule } from '../../../../lib/chains/registry' import { ChainActionRefused, @@ -116,7 +116,8 @@ async function chainRaceFacts( text: string ): Promise<{ intent: ParsedIntent; market: MarketContext } | string> { try { - assertKnownTokens(chainApi, action) + const known = assertKnownTokens(chainApi, action) + await assertAmountWithinCap(chainApi, action, known) } catch (e) { if (e instanceof ChainActionRefused) return `${e.message} Nothing was signed or sent.` throw e diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index 14b6798c..29158785 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -32,7 +32,7 @@ vi.mock('../swap/limit-price', () => ({ fetchOrderBookTop: async () => undefined const action = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } -function nearModule(quote: unknown) { +function nearModule(quote: unknown, price: number | null = 4) { return { chain: 'near', tokens: [ @@ -41,7 +41,7 @@ function nearModule(quote: unknown) { ], venues: [{ id: 'ref', name: 'Ref', kinds: ['swap'], quote }], defaultVenue: {}, - priceUsd: async () => 4, + priceUsd: async () => price ?? undefined, } } @@ -153,4 +153,34 @@ describe('POST /api/agents/compete on a chain', () => { await (await POST(post({ text: 'swap', chain: 'near', account }))).text() expect(enforceRateLimit).toHaveBeenCalledWith(expect.anything(), 'compete', account) }) + + describe('refuses an amount before any venue or agent is called', () => { + const cases: [string, string, number | null][] = [ + ['a non-numeric amount', 'lots', 4], + ['a zero amount', '0', 4], + ['a negative amount', '-3', 4], + ['an amount over the trade cap', '1000000000', 4], + ['a token with no live price', '5', null], + ] + it.each(cases)('%s', async (_name, amount, price) => { + const quote = vi.fn(async () => ({ receive: { amount: '1', symbol: 'USDC' } })) + state.module = nearModule(quote, price) + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'swap NEAR to USDC', + chain: 'near', + account: 'a.near', + chainAction: { ...action, amount }, + }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]?.type).toBe('competition:error') + expect(out[0]?.message).toContain('Nothing was signed or sent') + expect(quote).not.toHaveBeenCalled() + expect(state.propose).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index 1fc3477e..406f4514 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -63,21 +63,19 @@ export function assertKnownTokens(chainApi: ChainModule, request: ChainActionReq } /** - * Prepares an action, with the same order of checks as every other money route: - * known tokens, a dollar value under the cap, then the build, then a check of - * what the venue's API returned before anyone is asked to sign it. + * Refuses an amount that is not a positive number, a token with no live price, + * and a trade over the dollar cap. Shared with the agent race so a refused + * amount is refused in the same words before any agent or venue is called. * * The cap always reads as mainnet, because these chains only run on mainnet. The * Stellar network a deployment defaults to says nothing about Solana's funds. */ -export async function prepareChainAction( +export async function assertAmountWithinCap( chainApi: ChainModule, request: ChainActionRequest, - account: string, + tokenIn: ChainToken, env: Record = process.env -): Promise { - const tokenIn = assertKnownTokens(chainApi, request) - +): Promise { const price = await chainApi.priceUsd(tokenIn) const amount = Number(request.amount) if (!Number.isFinite(amount) || amount <= 0) { @@ -95,6 +93,25 @@ export async function prepareChainAction( : 'This trade could not be valued in dollars right now, so it was not built. Try again shortly.' ) } +} + +/** + * Prepares an action, with the same order of checks as every other money route: + * known tokens, a dollar value under the cap, then the build, then a check of + * what the venue's API returned before anyone is asked to sign it. + * + * The cap always reads as mainnet, because these chains only run on mainnet. The + * Stellar network a deployment defaults to says nothing about Solana's funds. + */ +export async function prepareChainAction( + chainApi: ChainModule, + request: ChainActionRequest, + account: string, + env: Record = process.env +): Promise { + const tokenIn = assertKnownTokens(chainApi, request) + + await assertAmountWithinCap(chainApi, request, tokenIn, env) const venue = pickVenue(chainApi, request) const prepared = await venue.prepare(request, account) From e98d16e5909fdcef2e3bab29119fddd9e19dc082 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:15:14 +0100 Subject: [PATCH 04/24] feat(dapp): give agents each chain's own facts Off Stellar the agents get a mainnet prompt with one fill, the chain's own venues and no Stellar tokens, and routes scored in dollars from display amounts. Supply and stake routes rank on APY and carry no slippage. --- apps/dapp/app/api/agents/compete/route.ts | 47 +++- .../lib/__tests__/agents-chain-prompt.test.ts | 253 ++++++++++++++++++ .../lib/agents/brains/openai-compatible.ts | 49 +++- apps/dapp/lib/agents/brief.ts | 36 +++ apps/dapp/lib/agents/chain-routes.ts | 27 +- apps/dapp/lib/agents/market-context.ts | 30 ++- apps/dapp/lib/agents/measure.ts | 25 ++ apps/dapp/lib/agents/plan-scoring.ts | 26 ++ apps/dapp/lib/agents/settle-chain-plan.ts | 20 ++ 9 files changed, 484 insertions(+), 29 deletions(-) create mode 100644 apps/dapp/lib/__tests__/agents-chain-prompt.test.ts create mode 100644 apps/dapp/lib/agents/settle-chain-plan.ts diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 07aafbdc..eb83e84c 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -3,11 +3,15 @@ import { NextResponse } from 'next/server' import { randomUUID } from 'node:crypto' import type { AgentProposalResult, MarketContext } from '../../../../lib/agents/brain' -import { intentFromChainAction, quoteChainRoutes } from '../../../../lib/agents/chain-routes' +import { + intentFromChainAction, + quoteChainRoutes, + type ChainRouteQuote, +} from '../../../../lib/agents/chain-routes' import type { CompetitionFrame } from '../../../../lib/agents/events' import { encodeFrame } from '../../../../lib/agents/events' import { buildMarketContextAsync, quoteRoutes } from '../../../../lib/agents/market-context' -import { measureRoute } from '../../../../lib/agents/measure' +import { measureChainRoute, measureRoute } from '../../../../lib/agents/measure' import { benchedUntilFor, lineUp, noteRateLimited } from '../../../../lib/agents/lineup' import { getRoster } from '../../../../lib/agents/registry' import type { PlanContext } from '../../../../lib/agents/plan-scoring' @@ -29,6 +33,7 @@ import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { fetchOrderBookTop } from '../../../../lib/swap/limit-price' import type { OrderBookTop } from '../../../../lib/swap/limit-price' import type { SwapQuote } from '../../../../lib/swap/quote' +import { settleChainPlan } from '../../../../lib/agents/settle-chain-plan' import { resolveExecutionPlan } from '../../../../lib/agents/tool-schema' import type { ParsedIntent } from '../../../../lib/parse-intent' import { logRace, type RaceInput } from '../../../../lib/server/analytics' @@ -137,15 +142,9 @@ async function chainRaceFacts( const price = tokenIn === undefined ? undefined : await chainApi.priceUsd(tokenIn).catch(() => undefined) - // Stellar's assets, lending rates and anchors are left out: they describe a - // different chain, and an agent shown them would reason about venues that do - // not exist here. - const { - assets: _assets, - lending: _lending, - offramps: _offramps, - ...base - } = await buildMarketContextAsync(chain) + // The chain's market already leaves out Stellar's assets, prices, lending + // rates and anchors; see `buildMarketContext`. + const base = await buildMarketContextAsync(chain) const market: MarketContext = { ...base, prices: price !== undefined && tokenIn !== undefined ? { [tokenIn.symbol]: price } : {}, @@ -212,10 +211,18 @@ function settlePlan( */ function measured( proposal: AgentProposalResult, - quote: SwapQuote | undefined, + quote: SwapQuote | ChainRouteQuote | undefined, prices: Record ): AgentProposalResult { if (quote === undefined) return proposal + if ('kind' in quote && quote.kind === 'chain') { + // Slippage against an oracle is what a trade costs; a supply or a stake + // has none, and measureChainRoute reports it as zero. The agent's own + // price stays: it is a rate, and the quote has no single price to replace it. + const m = measureChainRoute(quote) + if (m === undefined) return proposal + return { ...proposal, projectedSlippagePct: Number(m.vsInPct.toFixed(2)) } + } const m = measureRoute(quote, prices) if (m === undefined) return proposal return { @@ -497,8 +504,10 @@ export async function POST(request: Request): Promise { const own = (market.routes ?? []).find((r) => r.id === outcome.proposal.routeId) const proposal = measured( - settlePlan(outcome.proposal, intent, book), - own?.quote as SwapQuote | undefined, + chainFacts === undefined + ? settlePlan(outcome.proposal, intent, book) + : settleChainPlan(outcome.proposal), + own?.quote as SwapQuote | ChainRouteQuote | undefined, market.prices ) @@ -550,6 +559,15 @@ export async function POST(request: Request): Promise { const followOn = compound?.followOn.kind const anchorLimits = (market.offramps ?? []).find((o) => o.asset === 'USDC') + // A supply or a stake is ranked on the rate each route shows. + const apyEntries = (market.routes ?? []).flatMap((r) => { + const q = r.quote as Partial | undefined + return q?.kind === 'chain' && q.apyPercent !== undefined + ? [[r.id, q.apyPercent] as const] + : [] + }) + const apyByRoute = apyEntries.length > 0 ? Object.fromEntries(apyEntries) : undefined + const plan: PlanContext = { escrowUsd: intent.escrowUsd, referencePriceUsd: intent.referencePriceUsd, @@ -565,6 +583,7 @@ export async function POST(request: Request): Promise { ...(book?.ask !== undefined ? { recentHighUsd: book.ask } : {}), ...(anchorLimits?.maxAmount !== undefined ? { offrampMaxUsd: anchorLimits.maxAmount } : {}), ...(anchorLimits?.minAmount !== undefined ? { offrampMinUsd: anchorLimits.minAmount } : {}), + ...(apyByRoute !== undefined ? { apyByRoute } : {}), } const scored = scoreProposals(proposals, { competitionId, plan }) diff --git a/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts new file mode 100644 index 00000000..22ddbe06 --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' + +import type { AgentProposalResult, ProposalRequest } from '../agents/brain' +import { buildMessages } from '../agents/brains/openai-compatible' +import { SYSTEM_PROMPT, systemPromptFor } from '../agents/brief' +import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes' +import { buildMarketContext } from '../agents/market-context' +import { measureChainRoute } from '../agents/measure' +import { planPenalties, planScore } from '../agents/plan-scoring' +import { settleChainPlan } from '../agents/settle-chain-plan' +import type { ChainActionRequest, ChainModule, ChainToken } from '../chains/types' + +const swap: ChainActionRequest = { kind: 'swap', amount: '100', tokenIn: 'AAA', tokenOut: 'OUT' } + +function quoteOf(over: Partial): ChainRouteQuote { + return { + kind: 'chain', + request: swap, + venueId: 'v', + venueName: 'V', + pay: { amount: '100', symbol: 'AAA' }, + valueInUsd: 5, + ...over, + } +} + +describe('the prompt an agent on another chain is given', () => { + const prompt = systemPromptFor('near') + + it.each(['testnet', 'anchor', 'rest', 'split'])('never mentions %s', (word) => { + expect(prompt).not.toMatch(new RegExp(`\\b${word}\\b`, 'i')) + }) + + it('says the funds are real and the fill is single', () => { + expect(prompt).toMatch(/mainnet/i) + expect(prompt).toMatch(/real funds/i) + }) + + it('is exactly the existing prompt on Stellar', () => { + expect(systemPromptFor('stellar')).toBe(SYSTEM_PROMPT) + }) +}) + +describe('measuring a chain route', () => { + it('ranks $4.97 out above $4.74 out whatever each token’s decimals', async () => { + const decimals = [6, 9, 18, 24] + const outs = [ + { amount: '4.970000', price: 1 }, + { amount: '2.485', price: 2 }, + { amount: '4.74', price: 1 }, + { amount: '0.948', price: 5 }, + ] + const tokens: ChainToken[] = [ + { symbol: 'AAA', id: 'a', decimals: 7 }, + ...decimals.map((d, i) => ({ symbol: `T${i}`, id: `t${i}`, decimals: d })), + ] + const prices: Record = { AAA: 0.05, T0: 1, T1: 2, T2: 1, T3: 5 } + const chainApi = { + tokens, + defaultVenue: {}, + priceUsd: async (t: ChainToken) => prices[t.symbol], + venues: outs.map((o, i) => ({ + id: `v${i}`, + name: `V${i}`, + kinds: ['swap'], + quote: async () => ({ receive: { amount: o.amount, symbol: `T${i}` } }), + })), + } as unknown as ChainModule + + const routes = await quoteChainRoutes(chainApi, swap, 'acct') + const usd = routes.map((r) => measureChainRoute(r.quote as ChainRouteQuote)?.outUsd) + expect(usd[0]).toBeCloseTo(4.97, 6) + expect(usd[1]).toBeCloseTo(4.97, 6) + expect(usd[2]).toBeCloseTo(4.74, 6) + expect(usd[3]).toBeCloseTo(4.74, 6) + expect(usd[0] as number).toBeGreaterThan(usd[2] as number) + }) + + it('takes the venue’s own dollar value over a computed one', () => { + const m = measureChainRoute( + quoteOf({ valueOutUsd: 4.5, receive: { amount: '9', symbol: 'X' } }) + ) + expect(m?.outUsd).toBe(4.5) + expect(m?.vsInPct).toBeCloseTo(10, 6) + }) + + it('is undefined for a swap it cannot price, not zero', () => { + expect(measureChainRoute(quoteOf({ receive: { amount: '9', symbol: 'X' } }))).toBeUndefined() + const { valueInUsd: _unpriced, ...noValueIn } = quoteOf({ valueOutUsd: 4 }) + expect(measureChainRoute(noValueIn)).toBeUndefined() + }) + + it('does not charge a stake or a supply for slippage it cannot have', () => { + const stake = quoteOf({ + request: { kind: 'stake', amount: '100', tokenIn: 'AAA' }, + apyPercent: 4, + receive: { amount: '97', symbol: 'stAAA' }, + }) + expect(measureChainRoute(stake)).toEqual({ outUsd: 5, vsInPct: 0 }) + }) +}) + +describe('settling a chain plan', () => { + it('forces fill and no follow-on, whatever the agent chose', () => { + const proposal = { + agent: 'halcyon', + reasoning: 'x', + projectedAvgPriceUsd: 1, + projectedSlippagePct: 0, + venues: [], + sliceCount: 3, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'rest', + restPriceUsd: 4, + splitPct: 40, + thenAction: 'lend', + thenVenue: 'kamino', + } as unknown as AgentProposalResult + const settled = settleChainPlan(proposal) + expect(settled.executionMode).toBe('fill') + expect(settled.thenAction).toBeUndefined() + expect(settled.thenVenue).toBeUndefined() + expect(settled.restPriceUsd).toBeUndefined() + expect(settled.splitPct).toBeUndefined() + expect(settled.sliceCount).toBe(1) + }) +}) + +describe('the market an agent on Solana is given', () => { + const market = buildMarketContext('solana') + + it('offers only venues the app executes on', async () => { + const { venues } = await import('../venues') + const executes = new Set(venues.filter((v) => v.integration === 'executes').map((v) => v.id)) + expect(market.venues.length).toBeGreaterThan(0) + for (const v of market.venues) expect(executes.has(v.id)).toBe(true) + }) + + it('carries no Stellar tokens or prices', () => { + expect(market.assets ?? []).toEqual([]) + expect(market.prices).toEqual({}) + }) +}) + +describe('the routes as an agent reads them', () => { + function requestFor(routes: ProposalRequest['market']['routes']): ProposalRequest { + return { + agent: 'halcyon', + seat: 0, + chain: 'near', + intent: { + outcome: 'stake 100 NEAR', + escrowUsd: 400, + referencePriceUsd: 4, + targetPriceUsd: 4, + input: { + type: 'market_sell', + tokenIn: 'NEAR', + tokenOut: 'NEAR', + amountIn: '100', + minAmountOut: '0', + deadline: '2026-11-04T00:00:00.000Z', + }, + }, + market: { + asOf: '2026-11-04T00:00:00.000Z', + prices: { NEAR: 4 }, + venues: [{ id: 'meta', name: 'Meta Pool', category: 'staking' }], + volatilityHint: 'normal', + gasHint: 'normal', + ...(routes !== undefined ? { routes } : {}), + }, + } as ProposalRequest + } + + const stakeRoute = { + id: 'meta-1', + source: 'Meta Pool', + sendAmount: '100', + receiveAmount: '', + hops: 1, + executable: true, + quote: quoteOf({ + request: { kind: 'stake', amount: '100', tokenIn: 'NEAR' }, + pay: { amount: '100', symbol: 'NEAR' }, + apyPercent: 6.2, + }), + } + + it('shows a stake by its rate and never as a blank receive', () => { + const text = buildMessages(requestFor([stakeRoute])) + .map((m) => m.content) + .join('\n') + expect(text).toContain('6.2% APY') + expect(text).toContain('send 100 NEAR') + expect(text).not.toMatch(/receive\s*(on|$)/m) + expect(text).not.toMatch(/->\s*receive\s+on/) + }) + + it('does not call the chain a testnet', () => { + const text = buildMessages(requestFor([stakeRoute])) + .map((m) => m.content) + .join('\n') + expect(text).not.toMatch(/testnet/i) + }) + + it('leaves the Stellar prompt as it was', () => { + const req = { ...requestFor(undefined), chain: 'stellar' } + const first = buildMessages(req)[0] + expect(first?.content).toBe(SYSTEM_PROMPT) + }) +}) + +describe('ranking deposits and stakes by rate', () => { + const base = { + agent: 'halcyon', + reasoning: 'x', + projectedAvgPriceUsd: 1, + projectedSlippagePct: 0, + venues: [], + sliceCount: 1, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'fill', + } as unknown as AgentProposalResult + const plan = { + escrowUsd: 400, + referencePriceUsd: 4, + askedToLend: false, + askedToOfframp: false, + apyByRoute: { 'a-1': 6.2, 'b-1': 4.1 }, + } + + it('scores the lower rate below the higher one', () => { + const high = planScore({ ...base, routeId: 'a-1' }, plan) + const low = planScore({ ...base, routeId: 'b-1' }, plan) + expect(high).toBe(100) + expect(low).toBeCloseTo(97.9, 1) + }) + + it('says why in the penalty', () => { + const [p] = planPenalties({ ...base, routeId: 'b-1' }, plan) + expect(p?.reason).toBe('lower_rate') + expect(p?.detail).toContain('4.1%') + expect(p?.detail).toContain('6.2%') + }) + + it('leaves a swap race, which has no rates, untouched', () => { + const { apyByRoute: _ignored, ...swapPlan } = plan + expect(planPenalties({ ...base, routeId: 'b-1' }, swapPlan)).toEqual([]) + }) +}) diff --git a/apps/dapp/lib/agents/brains/openai-compatible.ts b/apps/dapp/lib/agents/brains/openai-compatible.ts index 43d26563..230795b3 100644 --- a/apps/dapp/lib/agents/brains/openai-compatible.ts +++ b/apps/dapp/lib/agents/brains/openai-compatible.ts @@ -6,10 +6,12 @@ import type { BrainProvider, ProposalOutcome, ProposalRequest, + QuotedRoute, } from '../brain' import { configuredLendingVenues } from '../../lend/venues' import { anchorOn, anchorsOn } from '../../offramp/anchors' -import { SYSTEM_PROMPT } from '../brief' +import { systemPromptFor } from '../brief' +import type { ChainRouteQuote } from '../chain-routes' import { promptSafe } from '../prompt-safe' import { SUBMIT_PROPOSAL_TOOL, validateProposal } from '../tool-schema' import type { ProviderConfig } from './providers' @@ -139,7 +141,7 @@ export function buildMessages(req: ProposalRequest): { role: string; content: st .join(', ') return [ - { role: 'system', content: SYSTEM_PROMPT }, + { role: 'system', content: systemPromptFor(req.chain) }, { role: 'system', content: [ @@ -203,8 +205,16 @@ export function buildMessages(req: ProposalRequest): { role: string; content: st // The routes above are live; this table is indicative and can be badly // stale. Saying so stops an agent splitting the difference between the // two and quoting a price neither source supports. - `Market reference (context only, NOT the number to quote): $${req.intent.referencePriceUsd} per ${req.intent.input.tokenOut}`, - 'Your projectedAvgPriceUsd must come from the route you picked, not from that reference.', + ...(req.chain === 'stellar' + ? [ + `Market reference (context only, NOT the number to quote): $${req.intent.referencePriceUsd} per ${req.intent.input.tokenOut}`, + 'Your projectedAvgPriceUsd must come from the route you picked, not from that reference.', + ] + : req.intent.referencePriceUsd > 0 + ? [ + `Market reference: $${req.intent.referencePriceUsd} per ${req.intent.input.tokenIn}, the token being paid in.`, + ] + : []), req.intent.targetPriceUsd > 0 ? `Target price: $${req.intent.targetPriceUsd}` : '', ...clarifiedLines(req.context), '', @@ -261,6 +271,35 @@ function perpLines(req: ProposalRequest): string[] { ] } +/** + * The same routes on a chain other than Stellar. + * + * A supply or a stake hands back no amount, so it is shown by its rate and a + * blank "receive" is never printed. The dollar value a swap delivers is shown + * beside the amount because tokens of different decimals are only comparable + * in dollars. Nothing here calls the market synthetic: these are real quotes. + */ +function chainRouteLines(routes: QuotedRoute[]): string[] { + const line = (r: QuotedRoute): string => { + const q = r.quote as Partial | undefined + const pay = q?.pay !== undefined ? `${q.pay.amount} ${q.pay.symbol}` : r.sendAmount + const gets = + q?.receive !== undefined + ? ` -> receive ${q.receive.amount} ${q.receive.symbol}` + + (q.valueOutUsd !== undefined ? ` (about $${q.valueOutUsd.toFixed(2)})` : '') + : '' + const rate = q?.apyPercent !== undefined ? `, ${q.apyPercent}% APY (current rate)` : '' + return ` ${r.id}: send ${pay}${gets} on ${promptSafe(r.source)}${rate}` + } + return [ + '', + 'Routes, already priced by each venue:', + ...routes.map(line), + 'Choose one by putting its id in routeId. These figures are measured, not estimates,', + 'so do not quote a better number than the route you picked actually offers.', + ] +} + /** * Presents the priced routes the agent must choose between. * @@ -284,6 +323,8 @@ function routeLines(req: ProposalRequest): string[] { ] } + if (req.chain !== 'stellar') return chainRouteLines(routes) + return [ '', 'Executable routes, already priced against live liquidity:', diff --git a/apps/dapp/lib/agents/brief.ts b/apps/dapp/lib/agents/brief.ts index 6791adc6..431a7dbd 100644 --- a/apps/dapp/lib/agents/brief.ts +++ b/apps/dapp/lib/agents/brief.ts @@ -62,3 +62,39 @@ thenAction is independent of executionMode. Filling now and then lending is a va export const SYSTEM_PROMPT = `${SHARED_RULES} ${STRATEGIST_BRIEF}` + +/** + * What an agent is told on a chain other than Stellar. + * + * Stellar's brief talks about resting orders, splitting and anchors, none of + * which exist on these chains, and the route notes call the market a testnet. + * Here the order is a single fill on mainnet with real funds, and the agent's + * one decision is which listed route to take. The chain action settles to a + * plain fill afterward whatever an agent says, so the brief does not offer + * choices that would be ignored. + */ +const CHAIN_BRIEF = `You are one of several autonomous execution agents judging a single user action on a stablecoin-native marketplace. + +This is mainnet and the funds are real funds. The action is a single transaction, signed once by the user. There is no second step, and nothing waits for a better price. + +Your decision is which of the listed routes to take. A route is a real quote from a venue on the chain named in the market context, and the venues listed are every venue that exists for this action. Naming one that is not listed fails your proposal outright. When only one route is listed you still decide: judge from its numbers whether it is worth taking, and say so. + +How to decide: +- Use ONLY the prices and figures given to you. Never use a price or a yield from memory; if a figure is not supplied, reason in relative terms instead. +- For a swap, compare the dollar value each route delivers for the same amount paid. The route that delivers the most is usually right, unless its figures look off against the others. +- For a deposit or a stake, compare the APY each route shows. It is the current rate, not a forecast, and it can change. +- Be concrete and quantitative. State the actual numbers you are working from. +- You are proposing an execution plan, not giving financial advice. + +Return your answer by calling the submit_proposal tool. Do not reply with prose. Fill the remaining fields like this: +- routeId: the id of the route you chose. +- projectedAvgPriceUsd: for a swap, the units paid per one unit received, read off the route you chose. For a deposit or a stake, the dollar price of the token being put in, from the market context. +- projectedSlippagePct: 0. The server measures the real figure from the quote. +- executionMode "fill", thenAction "none", sliceCount 1, and 0 for every other price or percentage field. + +Your reasoning is shown directly to the user in a chat bubble: at most two sentences and under 300 characters, no preamble, no restating the question. Never use an em dash or an en dash; use a period or a comma. Decide quickly and commit. Do not enumerate alternatives you are not going to choose, and do not re-derive the same figure twice.` + +/** The brief for this chain. Stellar's is exactly the one it has always had. */ +export function systemPromptFor(chain: string): string { + return chain === 'stellar' ? SYSTEM_PROMPT : CHAIN_BRIEF +} diff --git a/apps/dapp/lib/agents/chain-routes.ts b/apps/dapp/lib/agents/chain-routes.ts index ecb62412..5e1ad1f5 100644 --- a/apps/dapp/lib/agents/chain-routes.ts +++ b/apps/dapp/lib/agents/chain-routes.ts @@ -1,4 +1,4 @@ -import type { ChainActionRequest, ChainModule } from '../chains/types' +import { tokenBySymbol, type ChainActionRequest, type ChainModule } from '../chains/types' import type { ParsedIntent } from '../parse-intent' import type { QuotedRoute } from './brain' @@ -36,11 +36,33 @@ export async function quoteChainRoutes( (named === undefined || v.id.toLowerCase() === named || v.name.toLowerCase().includes(named)) ) + // Dollars per display unit, from the chain's own price source. Display + // amounts times this is the only value computed here: base units are never + // converted, because token decimals differ from chain to chain. + const priceOf = async (symbol: string): Promise => { + const token = tokenBySymbol(chainApi.tokens, symbol) + if (token === undefined) return undefined + const price = await chainApi.priceUsd(token).catch(() => undefined) + return price !== undefined && Number.isFinite(price) && price > 0 ? price : undefined + } + const priceIn = await priceOf(request.tokenIn) + const amountIn = Number(request.amount) + const valueInUsd = + priceIn !== undefined && Number.isFinite(amountIn) ? amountIn * priceIn : undefined + const settled = await Promise.all( usable.map(async (venue): Promise => { try { const quoted = await venue.quote?.(request, account) if (quoted === undefined) return undefined + // The venue's own dollar figure when it gives one; otherwise what it + // says it will hand over, priced here. Unpriced stays unknown. + let valueOutUsd = quoted.valueOutUsd + if (valueOutUsd === undefined && quoted.receive !== undefined) { + const priceOut = await priceOf(quoted.receive.symbol) + const qty = Number(quoted.receive.amount) + if (priceOut !== undefined && Number.isFinite(qty)) valueOutUsd = qty * priceOut + } const quote: ChainRouteQuote = { kind: 'chain', request, @@ -49,7 +71,8 @@ export async function quoteChainRoutes( pay: { amount: request.amount, symbol: request.tokenIn }, ...(quoted.receive !== undefined ? { receive: quoted.receive } : {}), ...(quoted.apyPercent !== undefined ? { apyPercent: quoted.apyPercent } : {}), - ...(quoted.valueOutUsd !== undefined ? { valueOutUsd: quoted.valueOutUsd } : {}), + ...(valueInUsd !== undefined ? { valueInUsd } : {}), + ...(valueOutUsd !== undefined ? { valueOutUsd } : {}), } return { id: `${venue.id}-1`, diff --git a/apps/dapp/lib/agents/market-context.ts b/apps/dapp/lib/agents/market-context.ts index 4c041593..9c8317ec 100644 --- a/apps/dapp/lib/agents/market-context.ts +++ b/apps/dapp/lib/agents/market-context.ts @@ -393,21 +393,30 @@ export function buildMarketContext( // failing later. DeFindex needs a key; without one it does not exist here. const lendingOffered: string[] = configuredLendingVenues(env, network) + // Solana, Sui and NEAR have their own venues and tokens. The reference + // prices and the asset list below describe Stellar, and an agent shown them + // would reason about tokens that do not exist on its chain. + const ownChain = family === 'solana' || family === 'sui' || family === 'near' + return { asOf: new Date().toISOString(), - prices: { ...REFERENCE_PRICES_USD }, + prices: ownChain ? {} : { ...REFERENCE_PRICES_USD }, // What the agent may actually trade, with a plain-English description of // each. Without this an agent has no way to know tokenized sovereign debt // is on the menu, and would reason only about the currencies it has seen // in prior prompts. - assets: tradeableSymbols().map((code) => ({ - code, - what: verificationOf(code)?.description ?? code, - // How well the issuer is established. An agent weighing an unfamiliar - // asset should know whether the organisation named actually confirms it - // issued the thing, and the difference is invisible from a ticker. - trust: trustSummary(code) ?? 'Verification unknown.', - })), + ...(ownChain + ? {} + : { + assets: tradeableSymbols().map((code) => ({ + code, + what: verificationOf(code)?.description ?? code, + // How well the issuer is established. An agent weighing an unfamiliar + // asset should know whether the organisation named actually confirms it + // issued the thing, and the difference is invisible from a ticker. + trust: trustSummary(code) ?? 'Verification unknown.', + })), + }), // Naming a venue that does not exist on the active chain is a plausible // failure for a model, so the list it may choose from is filtered here // rather than validated after the fact. @@ -416,6 +425,9 @@ export function buildMarketContext( // And on this network. A Stellar venue whose mainnet contracts are not // verified is testnet-only, and an agent must not be offered it. .filter((v) => v.family !== 'stellar' || isVenueOn(v, network)) + // On a chain with its own module, only a venue the app can sign for. + // One that is merely quoted would win a race it cannot complete. + .filter((v) => !ownChain || v.integration === 'executes') // Anchors are follow-on destinations, named through `thenVenue` and // validated against the anchor registry; they are not places a trade // executes and must not be selectable as a swap venue. diff --git a/apps/dapp/lib/agents/measure.ts b/apps/dapp/lib/agents/measure.ts index a1f83f69..094bc8ff 100644 --- a/apps/dapp/lib/agents/measure.ts +++ b/apps/dapp/lib/agents/measure.ts @@ -1,4 +1,5 @@ import { fromBaseUnits } from '../swap/assets' +import type { ChainRouteQuote } from './chain-routes' /** * What a route is actually worth, measured rather than claimed. @@ -72,3 +73,27 @@ export function measureRoute( efficiency, } } + +/** + * What a chain route is worth in dollars, for ranking routes against each + * other on chains whose tokens have different decimals. + * + * The figures come from the quote's dollar values, which were priced from + * display amounts. Nothing here reads base units. A route that cannot be + * priced is undefined, never zero: an unpriced route is not a free one. + * + * A supply or a stake keeps its principal rather than trading it, so it has no + * slippage to measure; it is reported at no loss and ranks on its rate instead. + */ +export function measureChainRoute( + q: ChainRouteQuote +): { outUsd: number; vsInPct: number } | undefined { + const valueIn = q.valueInUsd + if (valueIn === undefined || !Number.isFinite(valueIn) || valueIn <= 0) return undefined + + if (q.request.kind !== 'swap') return { outUsd: valueIn, vsInPct: 0 } + + const valueOut = q.valueOutUsd + if (valueOut === undefined || !Number.isFinite(valueOut) || valueOut <= 0) return undefined + return { outUsd: valueOut, vsInPct: (1 - valueOut / valueIn) * 100 } +} diff --git a/apps/dapp/lib/agents/plan-scoring.ts b/apps/dapp/lib/agents/plan-scoring.ts index 766191c2..3b6a2134 100644 --- a/apps/dapp/lib/agents/plan-scoring.ts +++ b/apps/dapp/lib/agents/plan-scoring.ts @@ -46,6 +46,12 @@ export interface PlanContext { offrampMaxUsd?: number /** The anchor's published minimum, when an offramp was offered. */ offrampMinUsd?: number + /** + * The APY each offered route shows, by route id, on a supply or a stake race. + * Those routes have no slippage to rank on, so the rate gap does it. Absent + * on a swap race. + */ + apyByRoute?: Record } export interface PlanPenalty { @@ -57,6 +63,7 @@ export interface PlanPenalty { | 'follow_on_not_worth_fee' | 'offramp_outside_limits' | 'slices_without_split' + | 'lower_rate' /** Points subtracted from the route score. */ points: number /** One line a person can read, naming the figures it was judged on. */ @@ -163,6 +170,22 @@ function followOnPenalties(proposal: AgentProposalResult, context: PlanContext): return out } +/** One point of score per percentage point of APY given up against the best route offered. */ +function ratePenalty(proposal: AgentProposalResult, context: PlanContext): PlanPenalty | undefined { + const rates = context.apyByRoute + if (rates === undefined || proposal.routeId === undefined) return undefined + const own = rates[proposal.routeId] + if (own === undefined) return undefined + const best = Math.max(...Object.values(rates)) + const gap = best - own + if (gap <= 0) return undefined + return { + reason: 'lower_rate', + points: Number(gap.toFixed(2)), + detail: `Takes ${own}% APY where ${best}% was offered.`, + } +} + function slicePenalty(proposal: AgentProposalResult): PlanPenalty | undefined { if (proposal.sliceCount <= 1) return undefined if (proposal.executionMode === 'split') return undefined @@ -182,6 +205,9 @@ export function planPenalties(proposal: AgentProposalResult, context: PlanContex out.push(...followOnPenalties(proposal, context)) + const rate = ratePenalty(proposal, context) + if (rate !== undefined) out.push(rate) + const slices = slicePenalty(proposal) if (slices !== undefined) out.push(slices) diff --git a/apps/dapp/lib/agents/settle-chain-plan.ts b/apps/dapp/lib/agents/settle-chain-plan.ts new file mode 100644 index 00000000..e8d55874 --- /dev/null +++ b/apps/dapp/lib/agents/settle-chain-plan.ts @@ -0,0 +1,20 @@ +import type { AgentProposalResult } from './brain' + +/** + * A plan on a chain other than Stellar is one fill and nothing after it. + * + * Resting, splitting and a follow-on lend or offramp have no way to execute + * there, and the prompt does not offer them. A model that picks one anyway + * keeps its route and its reasoning; only the shape of the plan is corrected, + * so scoring never rewards or punishes a choice that could not happen. + */ +export function settleChainPlan(proposal: AgentProposalResult): AgentProposalResult { + const { + restPriceUsd: _rest, + splitPct: _split, + thenAction: _then, + thenVenue: _venue, + ...rest + } = proposal + return { ...rest, executionMode: 'fill', sliceCount: 1 } +} From 1f5c6ca7fdd5fd6853e06cdffa8fba6ef3b1117b Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:19:23 +0100 Subject: [PATCH 05/24] fix(dapp): keep an unpriced swap from winning and show dollars on chain routes --- apps/dapp/app/api/agents/compete/route.ts | 25 +++--- .../lib/__tests__/agents-chain-prompt.test.ts | 86 ++++++++++++++++++- apps/dapp/lib/agents/brief.ts | 2 +- apps/dapp/lib/agents/plan-scoring.ts | 20 +++++ apps/dapp/lib/agents/settle-chain-plan.ts | 37 ++++++++ 5 files changed, 158 insertions(+), 12 deletions(-) diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index eb83e84c..3da0ac71 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -11,7 +11,7 @@ import { import type { CompetitionFrame } from '../../../../lib/agents/events' import { encodeFrame } from '../../../../lib/agents/events' import { buildMarketContextAsync, quoteRoutes } from '../../../../lib/agents/market-context' -import { measureChainRoute, measureRoute } from '../../../../lib/agents/measure' +import { measureRoute } from '../../../../lib/agents/measure' import { benchedUntilFor, lineUp, noteRateLimited } from '../../../../lib/agents/lineup' import { getRoster } from '../../../../lib/agents/registry' import type { PlanContext } from '../../../../lib/agents/plan-scoring' @@ -33,7 +33,11 @@ import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { fetchOrderBookTop } from '../../../../lib/swap/limit-price' import type { OrderBookTop } from '../../../../lib/swap/limit-price' import type { SwapQuote } from '../../../../lib/swap/quote' -import { settleChainPlan } from '../../../../lib/agents/settle-chain-plan' +import { + applyChainMeasure, + isUnmeasuredSwap, + settleChainPlan, +} from '../../../../lib/agents/settle-chain-plan' import { resolveExecutionPlan } from '../../../../lib/agents/tool-schema' import type { ParsedIntent } from '../../../../lib/parse-intent' import { logRace, type RaceInput } from '../../../../lib/server/analytics' @@ -215,14 +219,7 @@ function measured( prices: Record ): AgentProposalResult { if (quote === undefined) return proposal - if ('kind' in quote && quote.kind === 'chain') { - // Slippage against an oracle is what a trade costs; a supply or a stake - // has none, and measureChainRoute reports it as zero. The agent's own - // price stays: it is a rate, and the quote has no single price to replace it. - const m = measureChainRoute(quote) - if (m === undefined) return proposal - return { ...proposal, projectedSlippagePct: Number(m.vsInPct.toFixed(2)) } - } + if ('kind' in quote && quote.kind === 'chain') return applyChainMeasure(proposal, quote) const m = measureRoute(quote, prices) if (m === undefined) return proposal return { @@ -568,6 +565,13 @@ export async function POST(request: Request): Promise { }) const apyByRoute = apyEntries.length > 0 ? Object.fromEntries(apyEntries) : undefined + const unmeasuredRoutes = (market.routes ?? []) + .filter((r) => { + const q = r.quote as Partial | undefined + return q?.kind === 'chain' && isUnmeasuredSwap(q as ChainRouteQuote) + }) + .map((r) => r.id) + const plan: PlanContext = { escrowUsd: intent.escrowUsd, referencePriceUsd: intent.referencePriceUsd, @@ -584,6 +588,7 @@ export async function POST(request: Request): Promise { ...(anchorLimits?.maxAmount !== undefined ? { offrampMaxUsd: anchorLimits.maxAmount } : {}), ...(anchorLimits?.minAmount !== undefined ? { offrampMinUsd: anchorLimits.minAmount } : {}), ...(apyByRoute !== undefined ? { apyByRoute } : {}), + ...(unmeasuredRoutes.length > 0 ? { unmeasuredRoutes } : {}), } const scored = scoreProposals(proposals, { competitionId, plan }) diff --git a/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts index 22ddbe06..fe9b6946 100644 --- a/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts @@ -7,7 +7,8 @@ import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes' import { buildMarketContext } from '../agents/market-context' import { measureChainRoute } from '../agents/measure' import { planPenalties, planScore } from '../agents/plan-scoring' -import { settleChainPlan } from '../agents/settle-chain-plan' +import { pickWinner, scoreProposals } from '../agents/scoring' +import { applyChainMeasure, isUnmeasuredSwap, settleChainPlan } from '../agents/settle-chain-plan' import type { ChainActionRequest, ChainModule, ChainToken } from '../chains/types' const swap: ChainActionRequest = { kind: 'swap', amount: '100', tokenIn: 'AAA', tokenOut: 'OUT' } @@ -251,3 +252,86 @@ describe('ranking deposits and stakes by rate', () => { expect(planPenalties({ ...base, routeId: 'b-1' }, swapPlan)).toEqual([]) }) }) + +describe('what the panel is shown for a chain route', () => { + const claim = { + agent: 'halcyon', + routeId: 'v-1', + reasoning: 'x', + projectedAvgPriceUsd: 20.1, + projectedSlippagePct: 0, + venues: [], + sliceCount: 1, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'fill', + } as unknown as AgentProposalResult + + it('replaces a token ratio with the dollars paid per unit received', () => { + const q = quoteOf({ valueOutUsd: 4.97, receive: { amount: '4.97', symbol: 'USDC' } }) + const shown = applyChainMeasure(claim, q) + expect(shown.projectedAvgPriceUsd).toBeCloseTo(5 / 4.97, 6) + expect(shown.projectedSlippagePct).toBeCloseTo(0.6, 2) + }) + + it('shows the price of the token paid in for a stake', () => { + const q = quoteOf({ request: { kind: 'stake', amount: '100', tokenIn: 'AAA' }, apyPercent: 5 }) + const shown = applyChainMeasure(claim, q) + expect(shown.projectedAvgPriceUsd).toBeCloseTo(0.05, 6) + expect(shown.projectedSlippagePct).toBe(0) + }) + + it('claims nothing for a swap it cannot price', () => { + const q = quoteOf({ receive: { amount: '4.97', symbol: 'USDC' } }) + const shown = applyChainMeasure({ ...claim, projectedSlippagePct: 4 }, q) + expect(shown.projectedAvgPriceUsd).toBe(0) + expect(shown.projectedSlippagePct).toBe(0) + }) +}) + +describe('a swap route that could not be priced', () => { + const agent = (key: string, routeId: string, slippage: number): AgentProposalResult => + ({ + agent: key, + routeId, + reasoning: 'x', + projectedAvgPriceUsd: 1, + projectedSlippagePct: slippage, + venues: [], + sliceCount: 1, + confidence: 0.5, + horizonMinutes: 5, + executionMode: 'fill', + }) as unknown as AgentProposalResult + const plan = { + escrowUsd: 5, + referencePriceUsd: 0.05, + askedToLend: false, + askedToOfframp: false, + unmeasuredRoutes: ['unpriced-1'], + } + + it('is a swap with no dollar value out', () => { + expect(isUnmeasuredSwap(quoteOf({ receive: { amount: '4', symbol: 'X' } }))).toBe(true) + expect(isUnmeasuredSwap(quoteOf({ valueOutUsd: 4 }))).toBe(false) + }) + + it('loses to a priced route that really lost value', () => { + const scored = scoreProposals( + [agent('halcyon', 'unpriced-1', 0), agent('meridian', 'priced-1', 3)], + { competitionId: 'c', plan } + ) + expect(scored[0]?.agent).toBe('meridian') + expect(scored[1]?.penalties[0]?.reason).toBe('unpriced_route') + }) + + it('still picks a winner when every route is unpriced', () => { + const all = { ...plan, unmeasuredRoutes: ['unpriced-1', 'unpriced-2'] } + const scored = scoreProposals( + [agent('halcyon', 'unpriced-1', 0), agent('meridian', 'unpriced-2', 0)], + { competitionId: 'c', plan: all } + ) + expect(scored).toHaveLength(2) + expect(pickWinner(scored)).not.toBeNull() + }) +}) diff --git a/apps/dapp/lib/agents/brief.ts b/apps/dapp/lib/agents/brief.ts index 431a7dbd..8554668f 100644 --- a/apps/dapp/lib/agents/brief.ts +++ b/apps/dapp/lib/agents/brief.ts @@ -88,7 +88,7 @@ How to decide: Return your answer by calling the submit_proposal tool. Do not reply with prose. Fill the remaining fields like this: - routeId: the id of the route you chose. -- projectedAvgPriceUsd: for a swap, the units paid per one unit received, read off the route you chose. For a deposit or a stake, the dollar price of the token being put in, from the market context. +- projectedAvgPriceUsd: the dollar price of the token being paid in, from the market context. The server replaces it with the measured figure. - projectedSlippagePct: 0. The server measures the real figure from the quote. - executionMode "fill", thenAction "none", sliceCount 1, and 0 for every other price or percentage field. diff --git a/apps/dapp/lib/agents/plan-scoring.ts b/apps/dapp/lib/agents/plan-scoring.ts index 3b6a2134..b3cc94fc 100644 --- a/apps/dapp/lib/agents/plan-scoring.ts +++ b/apps/dapp/lib/agents/plan-scoring.ts @@ -52,6 +52,8 @@ export interface PlanContext { * on a swap race. */ apyByRoute?: Record + /** Swap routes the server could not price. Their value is unknown, so they cannot win on it. */ + unmeasuredRoutes?: string[] } export interface PlanPenalty { @@ -64,6 +66,7 @@ export interface PlanPenalty { | 'offramp_outside_limits' | 'slices_without_split' | 'lower_rate' + | 'unpriced_route' /** Points subtracted from the route score. */ points: number /** One line a person can read, naming the figures it was judged on. */ @@ -186,6 +189,20 @@ function ratePenalty(proposal: AgentProposalResult, context: PlanContext): PlanP } } +/** Enough to put a route nobody could price below every route that was measured. */ +function unpricedPenalty( + proposal: AgentProposalResult, + context: PlanContext +): PlanPenalty | undefined { + if (proposal.routeId === undefined) return undefined + if (!context.unmeasuredRoutes?.includes(proposal.routeId)) return undefined + return { + reason: 'unpriced_route', + points: 100, + detail: 'This route could not be priced, so what it delivers is unknown.', + } +} + function slicePenalty(proposal: AgentProposalResult): PlanPenalty | undefined { if (proposal.sliceCount <= 1) return undefined if (proposal.executionMode === 'split') return undefined @@ -208,6 +225,9 @@ export function planPenalties(proposal: AgentProposalResult, context: PlanContex const rate = ratePenalty(proposal, context) if (rate !== undefined) out.push(rate) + const unpriced = unpricedPenalty(proposal, context) + if (unpriced !== undefined) out.push(unpriced) + const slices = slicePenalty(proposal) if (slices !== undefined) out.push(slices) diff --git a/apps/dapp/lib/agents/settle-chain-plan.ts b/apps/dapp/lib/agents/settle-chain-plan.ts index e8d55874..ace36b6d 100644 --- a/apps/dapp/lib/agents/settle-chain-plan.ts +++ b/apps/dapp/lib/agents/settle-chain-plan.ts @@ -1,4 +1,6 @@ import type { AgentProposalResult } from './brain' +import type { ChainRouteQuote } from './chain-routes' +import { measureChainRoute } from './measure' /** * A plan on a chain other than Stellar is one fill and nothing after it. @@ -18,3 +20,38 @@ export function settleChainPlan(proposal: AgentProposalResult): AgentProposalRes } = proposal return { ...rest, executionMode: 'fill', sliceCount: 1 } } + +/** + * A swap the server could not price. Its value is unknown, not zero loss, so + * scoring must not let it beat a route that was measured. + */ +export function isUnmeasuredSwap(q: ChainRouteQuote): boolean { + return q.request.kind === 'swap' && measureChainRoute(q) === undefined +} + +/** + * Replaces what the agent claimed about its route with what the quote says. + * + * The panel shows `projectedAvgPriceUsd` with a dollar sign, so it holds + * dollars: paid per unit received for a swap, the price of the token paid in + * for a deposit or a stake. A swap that could not be priced shows zero for + * both figures rather than the agent's guess; scoring penalises it separately. + */ +export function applyChainMeasure( + proposal: AgentProposalResult, + q: ChainRouteQuote +): AgentProposalResult { + const m = measureChainRoute(q) + if (m === undefined) { + return q.request.kind === 'swap' + ? { ...proposal, projectedAvgPriceUsd: 0, projectedSlippagePct: 0 } + : proposal + } + const unit = Number(q.request.kind === 'swap' ? q.receive?.amount : q.pay.amount) + const avg = unit > 0 && q.valueInUsd !== undefined ? q.valueInUsd / unit : 0 + return { + ...proposal, + projectedAvgPriceUsd: Number(avg.toFixed(6)), + projectedSlippagePct: Number(m.vsInPct.toFixed(2)), + } +} From eee715aee941b3562944c72500c2608dfc3be3d6 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:23:52 +0100 Subject: [PATCH 06/24] feat(dapp): quote near venues for the agent race --- .../lib/__tests__/near-race-quote.test.ts | 192 ++++++++++++++++++ apps/dapp/lib/chains/near/earn.ts | 23 ++- apps/dapp/lib/chains/near/stake.ts | 35 ++++ apps/dapp/lib/chains/near/swap.ts | 65 +++++- 4 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 apps/dapp/lib/__tests__/near-race-quote.test.ts diff --git a/apps/dapp/lib/__tests__/near-race-quote.test.ts b/apps/dapp/lib/__tests__/near-race-quote.test.ts new file mode 100644 index 00000000..6ecb83e8 --- /dev/null +++ b/apps/dapp/lib/__tests__/near-race-quote.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { earnVenue } from '../chains/near/earn' +import { clearTokenCache } from '../chains/near/oneclick' +import { metapoolVenue, rnearVenue } from '../chains/near/stake' +import { oneClickVenue } from '../chains/near/swap' +import { METAPOOL_CONTRACT, RNEAR_CONTRACT, USDC_NEAR } from '../chains/near/tokens' +import type { ChainActionRequest } from '../chains/types' + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) + +const ME = 'alice.near' +const DEPOSIT = 'dc2c3034e3ebefa1af9a2c419f896d7b761a056244099eefcd09aeb7efc1b86d' + +type Json = Record + +interface Sim { + quotes: Json[] + override: ((body: Json) => { status: number; json: unknown } | Json | undefined) | undefined + apy: unknown + apyDown: boolean + views: Record +} + +let sim: Sim + +function goodQuote(body: Json): Json { + const amount = body['amount'] as string + const inDollars = Number(amount) / 1e6 + const outDollars = inDollars * 0.995 + const toNear = body['destinationAsset'] === 'nep141:wrap.near' + const out = toNear + ? BigInt(Math.round((outDollars / 4) * 1e6)) * BigInt('1000000000000000000') + : BigInt(Math.round(outDollars * 1e6)) * BigInt('1000000000000') + return { + quote: { + amountIn: amount, + amountInFormatted: '1', + amountInUsd: String(inDollars), + minAmountIn: amount, + amountOut: out.toString(), + amountOutFormatted: 'x', + amountOutUsd: String(outDollars), + minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(), + timeEstimate: 20, + refundFee: '0', + withdrawFee: '0', + ...(body['dry'] === true + ? {} + : { depositAddress: DEPOSIT, deadline: '2026-11-06T00:00:00.000Z' }), + }, + quoteRequest: { ...body, appFees: [{ recipient: 'fee.near', fee: 20 }] }, + signature: 'ed25519:x', + timestamp: '2026-11-03T00:00:00.000Z', + } +} + +beforeEach(() => { + clearTokenCache() + sim = { + quotes: [], + override: undefined, + apy: '4.5', + apyDown: false, + views: { + [`${RNEAR_CONTRACT}.ft_price`]: '1000000000000000000000000', + [`${METAPOOL_CONTRACT}.get_st_near_price`]: '2000000000000000000000000', + }, + } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status }) + if (url.endsWith('/get-rnear-apy')) { + if (sim.apyDown) throw new Error('down') + return json({ code: 0, data: sim.apy }) + } + if (url.endsWith('/v0/tokens')) { + const priceUpdatedAt = new Date().toISOString() + return json([ + { assetId: 'nep141:wrap.near', decimals: 24, symbol: 'wNEAR', price: 4, priceUpdatedAt }, + { assetId: `nep141:${USDC_NEAR}`, decimals: 6, symbol: 'USDC', price: 1, priceUpdatedAt }, + { + assetId: 'nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near', + decimals: 18, + symbol: 'gtUSDCp', + price: 1, + priceUpdatedAt, + }, + ]) + } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Json + sim.quotes.push(body) + const over = sim.override?.(body) + if (over !== undefined && 'status' in over) { + return json(over['json'], over['status'] as number) + } + return json(over ?? goodQuote(body), 201) + } + const rpc = JSON.parse(init?.body as string) as { + params: { account_id: string; method_name: string } + } + const key = `${rpc.params.account_id}.${rpc.params.method_name}` + if (!(key in sim.views)) return json({ result: { error: `no ${key}` } }) + return json({ result: { result: [...Buffer.from(JSON.stringify(sim.views[key]))] } }) + }) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const swap: ChainActionRequest = { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'NEAR' } +const supply: ChainActionRequest = { kind: 'supply', amount: '1', tokenIn: 'USDC' } +const stake: ChainActionRequest = { kind: 'stake', amount: '2', tokenIn: 'NEAR' } + +describe('quoting the 1Click swap for the race', () => { + it('asks for one dry quote and returns what it would give back', async () => { + const quoted = await oneClickVenue.quote?.(swap, ME) + expect(sim.quotes).toHaveLength(1) + expect(sim.quotes[0]).toMatchObject({ dry: true, originAsset: `nep141:${USDC_NEAR}` }) + expect(quoted?.receive?.symbol).toBe('NEAR') + expect(quoted?.valueOutUsd).toBeCloseTo(0.995) + }) + + it('never carries a deposit address, even if 1Click sends one', async () => { + sim.override = (body) => { + const full = goodQuote({ ...body, dry: false }) + return { ...full, quoteRequest: { ...body } } + } + const quoted = await oneClickVenue.quote?.(swap, ME) + expect(JSON.stringify(quoted)).not.toContain(DEPOSIT) + expect(quoted).not.toHaveProperty('depositAddress') + }) + + it('refuses a quote that does not say what was asked', async () => { + sim.override = (body) => { + const good = goodQuote(body) as { quoteRequest: Json } + return { ...good, quoteRequest: { ...good.quoteRequest, recipient: 'mallory.near' } } + } + await expect(oneClickVenue.quote?.(swap, ME)).rejects.toThrow(/didn't match/) + }) + + it('refuses a quote that gives back too little', async () => { + sim.override = (body) => { + const good = goodQuote(body) as { quote: Json } + return { ...good, quote: { ...good.quote, amountOutUsd: '0.5' } } + } + await expect(oneClickVenue.quote?.(swap, ME)).rejects.toThrow(/less than you pay/) + }) +}) + +describe('quoting the stake venues for the race', () => { + it('rNEAR returns the receipt amount and the rate from Rhea', async () => { + const quoted = await rnearVenue.quote?.(stake, ME) + expect(quoted).toEqual({ receive: { amount: '2', symbol: 'rNEAR' }, apyPercent: 4.5 }) + }) + + it('rNEAR gives no rate when Rhea cannot be read, rather than inventing one', async () => { + sim.apyDown = true + const quoted = await rnearVenue.quote?.(stake, ME) + expect(quoted?.receive?.symbol).toBe('rNEAR') + expect(quoted).not.toHaveProperty('apyPercent') + }) + + it('Meta Pool returns the receipt amount and no rate, because none can be read', async () => { + const quoted = await metapoolVenue.quote?.(stake, ME) + expect(quoted).toEqual({ receive: { amount: '1', symbol: 'stNEAR' } }) + }) + + it('throws when the contract cannot be read', async () => { + sim.views = {} + await expect(rnearVenue.quote?.(stake, ME)).rejects.toThrow(/could not be read/) + await expect(metapoolVenue.quote?.(stake, ME)).rejects.toThrow(/could not be read/) + }) +}) + +describe('quoting 1Click Earn for the race', () => { + it('uses a dry quote, returns the vault share, and no deposit address', async () => { + const quoted = await earnVenue.quote?.(supply, ME) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true]) + expect(quoted?.receive?.symbol).toBe('gtUSDCp') + expect(quoted).not.toHaveProperty('depositAddress') + expect(quoted).not.toHaveProperty('apyPercent') + }) +}) diff --git a/apps/dapp/lib/chains/near/earn.ts b/apps/dapp/lib/chains/near/earn.ts index f32fa5a4..11aa5fa6 100644 --- a/apps/dapp/lib/chains/near/earn.ts +++ b/apps/dapp/lib/chains/near/earn.ts @@ -2,12 +2,14 @@ import { ChainActionRefused, tokenBySymbol, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { assertOneClickSafe } from './safety' -import { prepareOneClickDeposit, submitOneClick } from './swap' +import { prepareOneClickDeposit, quoteOneClick, submitOneClick } from './swap' import { nearTokens, USDC_NEAR, USDT_NEAR } from './tokens' export const EARN_VENUE_ID = '1click-earn' @@ -31,11 +33,25 @@ const VAULT = { const EARN_NOTE = "Your stablecoin becomes a share in Gauntlet USDC Prime, a Morpho vault on Base, held in your NEAR Intents balance. That stacks bridge, NEAR Intents and vault risk, the principal isn't guaranteed, and 1Click publishes no rate to show here. This app can't show this position or withdraw it yet. Getting out takes a separate signed withdrawal through NEAR Intents." -async function prepareEarn(request: ChainActionRequest, account: string): Promise { +function stablecoinIn(request: ChainActionRequest): ChainToken { const tokenIn = tokenBySymbol(nearTokens, request.tokenIn) - if (request.kind !== 'supply' || (tokenIn?.id !== USDC_NEAR && tokenIn?.id !== USDT_NEAR)) { + if ( + request.kind !== 'supply' || + tokenIn === undefined || + (tokenIn.id !== USDC_NEAR && tokenIn.id !== USDT_NEAR) + ) { throw new ChainActionRefused(`${VENUE_NAME} here takes USDC or USDT.`) } + return tokenIn +} + +/** No apyPercent: 1Click publishes no rate for the vault, and a rate is never invented. */ +async function quoteEarn(request: ChainActionRequest, account: string): Promise { + return quoteOneClick(request.amount, stablecoinIn(request), VAULT, account) +} + +async function prepareEarn(request: ChainActionRequest, account: string): Promise { + const tokenIn = stablecoinIn(request) return prepareOneClickDeposit( { venueId: EARN_VENUE_ID, @@ -68,6 +84,7 @@ export const earnVenue: ChainVenue = { id: EARN_VENUE_ID, name: VENUE_NAME, kinds: ['supply'], + quote: quoteEarn, prepare: prepareEarn, assertSafe: assertEarnSafe, submit: submitOneClick, diff --git a/apps/dapp/lib/chains/near/stake.ts b/apps/dapp/lib/chains/near/stake.ts index 009cd178..854c5b75 100644 --- a/apps/dapp/lib/chains/near/stake.ts +++ b/apps/dapp/lib/chains/near/stake.ts @@ -8,6 +8,7 @@ import { type PreparedAction, type SubmitInput, type SubmitResult, + type VenueQuote, } from '../types' import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account' import { isRegistered, viewCall, waitForTransaction } from './rpc' @@ -81,6 +82,38 @@ function stakeCall(amount: string, gas: bigint): NearTransaction['actions'][numb } } +/** + * What a stake would give back, without building the transaction. A rate Rhea does not + * give is left out rather than guessed. Meta Pool has no rate endpoint here, so its quote + * carries none. + */ +async function quoteRnear(request: ChainActionRequest, account: string): Promise { + assertStakeRequest(request, account, 'rNEAR') + const amount = toBaseUnits(request.amount, 24) + let receive: string + try { + receive = receiptAmount(amount, await viewCall(RNEAR_CONTRACT, 'ft_price')) + } catch { + throw cannotRead('rNEAR') + } + const apyPercent = await rnearApyPercent() + return { + receive: { amount: receive, symbol: 'rNEAR' }, + ...(apyPercent !== undefined ? { apyPercent } : {}), + } +} + +async function quoteMetapool(request: ChainActionRequest, account: string): Promise { + assertStakeRequest(request, account, 'Meta Pool') + const amount = toBaseUnits(request.amount, 24) + try { + const price = await viewCall(METAPOOL_CONTRACT, 'get_st_near_price') + return { receive: { amount: receiptAmount(amount, price), symbol: 'stNEAR' } } + } catch { + throw cannotRead('Meta Pool') + } +} + async function prepareRnear(request: ChainActionRequest, account: string): Promise { const venue = 'rNEAR' assertStakeRequest(request, account, venue) @@ -190,6 +223,7 @@ export const rnearVenue: ChainVenue = { id: RNEAR_VENUE_ID, name: 'rNEAR', kinds: ['stake'], + quote: quoteRnear, prepare: prepareRnear, assertSafe: assertRnearSafe, } @@ -198,6 +232,7 @@ export const metapoolVenue: ChainVenue = { id: METAPOOL_VENUE_ID, name: 'Meta Pool', kinds: ['stake'], + quote: quoteMetapool, prepare: prepareMetapool, assertSafe: assertMetapoolSafe, } diff --git a/apps/dapp/lib/chains/near/swap.ts b/apps/dapp/lib/chains/near/swap.ts index 7fc45a9f..0ab120ef 100644 --- a/apps/dapp/lib/chains/near/swap.ts +++ b/apps/dapp/lib/chains/near/swap.ts @@ -9,6 +9,7 @@ import { type PreparedAction, type SubmitInput, type SubmitResult, + type VenueQuote, } from '../types' import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account' import { @@ -220,14 +221,17 @@ interface DepositFlow { extraNotes: string[] } -/** The quote flow behind every 1Click venue. Only the destination and the wording differ. */ -export async function prepareOneClickDeposit( - flow: DepositFlow, +/** + * The preview half of every 1Click flow: a dry quote, checked against what was asked and + * against live prices. A dry quote makes no deposit address, so the race can run it for + * every venue, and Review runs it again before the real quote. + */ +async function previewOneClick( amountText: string, tokenIn: ChainToken, destination: Destination, account: string -): Promise { +) { if (!NEAR_ACCOUNT.test(account)) { throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.') } @@ -245,6 +249,36 @@ export async function prepareOneClickDeposit( const preview = await requestQuote({ ...shared, dry: true, deadline: dryDeadline }) assertQuoteMatches(preview, { ...shared, dry: true, deadline: dryDeadline }) await assertFairValue(preview, tokenIn, destination) + return { shared, preview } +} + +/** What a dry quote says the swap would give back. Never reads a deposit address. */ +export async function quoteOneClick( + amountText: string, + tokenIn: ChainToken, + destination: Destination, + account: string +): Promise { + const { preview } = await previewOneClick(amountText, tokenIn, destination, account) + return { + receive: { + amount: formatUnits(preview.quote.amountOut, destination.decimals), + symbol: destination.symbol, + }, + valueOutUsd: Number(preview.quote.amountOutUsd), + } +} + +/** The quote flow behind every 1Click venue. Only the destination and the wording differ. */ +export async function prepareOneClickDeposit( + flow: DepositFlow, + amountText: string, + tokenIn: ChainToken, + destination: Destination, + account: string +): Promise { + const { shared } = await previewOneClick(amountText, tokenIn, destination, account) + const amount = shared.amount const deadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString() const live = await requestQuote({ ...shared, dry: false, deadline }) @@ -290,13 +324,33 @@ export async function prepareOneClickDeposit( } } -async function prepareSwap(request: ChainActionRequest, account: string): Promise { +function swapTokens(request: ChainActionRequest): { tokenIn: ChainToken; tokenOut: ChainToken } { const tokenIn = tokenBySymbol(nearTokens, request.tokenIn) const tokenOut = tokenBySymbol(nearTokens, request.tokenOut ?? '') if (request.kind !== 'swap' || tokenIn === undefined || tokenOut === undefined) { throw new ChainActionRefused('NEAR Intents here swaps between NEAR, USDC and USDT.') } if (tokenIn.id === tokenOut.id) throw new ChainActionRefused('Those are the same token.') + return { tokenIn, tokenOut } +} + +async function quoteSwap(request: ChainActionRequest, account: string): Promise { + const { tokenIn, tokenOut } = swapTokens(request) + return quoteOneClick( + request.amount, + tokenIn, + { + assetId: assetIdOf(tokenOut), + symbol: tokenOut.symbol, + decimals: tokenOut.decimals, + recipientType: 'DESTINATION_CHAIN', + }, + account + ) +} + +async function prepareSwap(request: ChainActionRequest, account: string): Promise { + const { tokenIn, tokenOut } = swapTokens(request) const toNear = tokenOut.id === WRAP_NEAR return prepareOneClickDeposit( { @@ -427,6 +481,7 @@ export const oneClickVenue: ChainVenue = { id: ONECLICK_VENUE_ID, name: 'NEAR Intents 1Click', kinds: ['swap'], + quote: quoteSwap, prepare: prepareSwap, assertSafe: assertOneClickSafe, submit: submitOneClick, From 47fbf15142377bb4764e1d7eb217ee74b9b991b2 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:34:43 +0100 Subject: [PATCH 07/24] feat(dapp): run the agent race on near from chat Typing a swap, supply or stake on Solana, Sui or NEAR starts the race, and Review on a route builds it through the chain's own card with only the venue taken from the route. When the race ends with nothing to review, the direct route is built and the card says so. A refused action gets its own error code so it is shown as written and not retried. --- apps/dapp/app/api/agents/compete/route.ts | 17 +- .../components/intents/competition-panel.tsx | 17 +- apps/dapp/components/intents/intent-chat.tsx | 91 +++++++++- apps/dapp/hooks/use-competition.ts | 25 ++- .../__tests__/agents-chain-compete.test.ts | 8 +- .../lib/__tests__/agents-chain-race.test.ts | 171 ++++++++++++++++++ apps/dapp/lib/agents/chain-race.ts | 132 ++++++++++++++ apps/dapp/lib/agents/competition.ts | 14 +- apps/dapp/lib/agents/events.ts | 4 +- apps/dapp/lib/orchestrate/capabilities.ts | 25 ++- 10 files changed, 484 insertions(+), 20 deletions(-) create mode 100644 apps/dapp/lib/__tests__/agents-chain-race.test.ts create mode 100644 apps/dapp/lib/agents/chain-race.ts diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 3da0ac71..3a060fa2 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -116,6 +116,9 @@ function chainActionFromBody(raw: unknown): ChainActionRequest | undefined { * What the agents are given for an action on a chain that is not Stellar: the * chain's own venues and real quotes, and none of Stellar's prices, assets or * order book. Returns the sentence to show instead when there is nothing to race. + * A `refusal` is the action itself being refused, which the user must read as + * written; a plain string is a race that could not run, where the direct route + * is still worth trying. */ async function chainRaceFacts( chain: string, @@ -123,12 +126,13 @@ async function chainRaceFacts( action: ChainActionRequest, account: string | undefined, text: string -): Promise<{ intent: ParsedIntent; market: MarketContext } | string> { +): Promise<{ intent: ParsedIntent; market: MarketContext } | string | { refusal: string }> { try { const known = assertKnownTokens(chainApi, action) await assertAmountWithinCap(chainApi, action, known) } catch (e) { - if (e instanceof ChainActionRefused) return `${e.message} Nothing was signed or sent.` + if (e instanceof ChainActionRefused) + return { refusal: `${e.message} Nothing was signed or sent.` } throw e } if (account === undefined) { @@ -230,7 +234,10 @@ function measured( } /** One SSE stream carrying a single error frame, then closing. */ -function errorStream(code: 'agents_offline' | 'chain_unsupported', message: string): Response { +function errorStream( + code: 'agents_offline' | 'chain_unsupported' | 'action_refused', + message: string +): Response { const encoder = new TextEncoder() const stream = new ReadableStream({ start(controller) { @@ -351,6 +358,9 @@ export async function POST(request: Request): Promise { ? await chainRaceFacts(chain, chainApi, chainAction, account, text) : undefined if (typeof chainFacts === 'string') return errorStream('chain_unsupported', chainFacts) + if (chainFacts !== undefined && 'refusal' in chainFacts) { + return errorStream('action_refused', chainFacts.refusal) + } const competitionId = randomUUID() // Taken here, in the request, so the race is recorded for the network it ran on. @@ -453,6 +463,7 @@ export async function POST(request: Request): Promise { // "thinking". Each agent is a model, and the user can see which. agents: roster.map(({ key, name, gradient, model }) => ({ key, name, gradient, model })), windowSeconds: WINDOW_SECONDS, + ...(chainFacts !== undefined ? { routeCount: market.routes?.length ?? 0 } : {}), }) // Agents run concurrently and are emitted as they finish, so one slow diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index fb92b356..ed3001df 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react' import type { CompetitionState } from '../../lib/agents/competition' +import { singleRouteNote } from '../../lib/agents/chain-race' import { describePlan } from '../../lib/agents/competition' import { AgentAvatar } from '../agents/agent-avatar' import { agentBubble, systemPill } from './bubble' @@ -84,6 +85,7 @@ export function CompetitionPanel({ onExecute, executingKey, locked = false, + chainName, }: { state: CompetitionState onExecute: (key: string) => void @@ -95,6 +97,8 @@ export function CompetitionPanel({ * disabling the buttons on the first pick made it one. */ locked?: boolean + /** The chain's name, set off Stellar so a single route can be explained. */ + chainName?: string }): JSX.Element | null { const { proposals, revealed, agents, phase, winner, error } = state const unanimous = state.unanimous === true @@ -106,6 +110,10 @@ export function CompetitionPanel({ // race that was not happening and never ended. if (phase === 'idle') return null + // A refusal of the action itself is shown by the chat, as written, in the + // action card; saying "agents are not online" over it would be wrong. + if (error?.code === 'action_refused') return null + if (error !== undefined) { return (
@@ -133,8 +141,12 @@ export function CompetitionPanel({ const pendingAgents = decided ? [] : agents.filter((a) => !revealed[a.key] && answered(a.key)) const anyAnswered = Object.values(proposals).some((p) => p.failed === undefined) + const routeNote = + chainName !== undefined ? singleRouteNote(state.routeCount, chainName) : undefined + return (
+ {routeNote !== undefined ?
{routeNote}
: null} {revealedAgents.length === 0 && !decided ? (
@@ -241,11 +253,12 @@ export function CompetitionPanel({ oracle's fair value. */}
- {money(proposal?.avgPriceUsd ?? 0)} avg + {proposal?.figures?.headline ?? + `${money(proposal?.avgPriceUsd ?? 0)} avg`} {[ - vsOracle(proposal?.vsOraclePct), + proposal?.figures !== undefined ? '' : vsOracle(proposal?.vsOraclePct), proposal?.executionMode === 'rest' && proposal.horizonMinutes !== undefined && proposal.horizonMinutes > 0 diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 629b1cd4..d9926212 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -24,6 +24,7 @@ import { StrategyEditor, type StrategyEdits } from './strategy-editor' import { DEFAULT_SLIPPAGE_BPS } from '../../lib/swap/build-tx' import { ACKNOWLEDGED } from '../../lib/chat/clarify-flow' import type { ClarifyTurn } from '../../lib/clarify/clarify' +import { needsDirectRoute, reviewChainRoute } from '../../lib/agents/chain-race' import { agentsFromProposals } from '../../lib/agents/competition' import type { CreateIntentInput } from '@intent/types' @@ -55,6 +56,7 @@ import { ensureSession } from '../../lib/api/auth' import { chainCapabilities } from '../../lib/chains/capabilities' import { chainHelp } from '../../lib/chains/explain' import { looksLikeChainAction, parseChainAction } from '../../lib/chains/parse-action' +import type { ChainActionRequest } from '../../lib/chains/types' import { useChainAction } from '../../hooks/use-chain-action' import { ChainActionConfirm } from './chain-action-confirm' import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout' @@ -188,6 +190,14 @@ export function IntentChat(): JSX.Element { const [message, setMessage] = useState(null) const [parsed, setParsed] = useState(null) const [executingKey, setExecutingKey] = useState(null) + // A swap, supply or stake typed on a chain other than Stellar, while its race + // runs. `parsed` stays null for it so Stellar's cards do not appear. + const [chainRace, setChainRace] = useState<{ request: ChainActionRequest; text: string } | null>( + null + ) + // Set when the race left nothing to review and the direct route was built, so + // the card can say why. + const [directFallback, setDirectFallback] = useState(false) // The intent placed in this conversation, so an order that is waiting for a // price can be watched — and withdrawn — without leaving the chat. const [placedId, setPlacedId] = useState(null) @@ -405,8 +415,9 @@ export function IntentChat(): JSX.Element { const live = useCompetition( restored === null ? parsed : null, slug, - [...raceContext(thread, notes), ...brief], - address + chainRace !== null ? [] : [...raceContext(thread, notes), ...brief], + address, + restored === null && chainRace !== null ? chainRace : undefined ) const liveCompetition = live @@ -456,6 +467,40 @@ export function IntentChat(): JSX.Element { // no earlier step whose output has to be carried. const supply = useSupply() const chainAction = useChainAction() + // A new race gets a clean slate when the chain changes. + useEffect(() => { + setChainRace(null) + }, [slug]) + // The race ended with nothing to review: build the direct route once, so the + // chain keeps working when the agents are offline. A refusal of the action + // is shown as written by the card and is not retried. + const fellBackRef = useRef(null) + // The race the hook has actually begun. On the render where a new race is + // asked for, the hook still holds the last one's ending, which must not be + // mistaken for this race's. + const startedRef = useRef(null) + const raceEnded = needsDirectRoute({ + phase: live.phase, + winner: live.winner, + anyAnswered: Object.values(live.proposals).some((p) => p.failed === undefined), + ...(live.error !== undefined ? { error: live.error } : {}), + }) + useEffect(() => { + if (chainRace === null || restored !== null || fellBackRef.current === chainRace) return + if (live.phase === 'competing') startedRef.current = chainRace + if (startedRef.current !== chainRace) return + if (live.error?.code === 'action_refused') { + fellBackRef.current = chainRace + chainAction.refuse(live.error.message, chainRace.text) + return + } + if (!raceEnded) return + fellBackRef.current = chainRace + setDirectFallback(true) + chainAction.prepare(chainRace.request, chainRace.text) + // chainAction changes identity with its state; the race ending is the trigger. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainRace, raceEnded, live.error, live.phase, restored]) const cashout = useCashout() const [cashoutQuestion, setCashoutQuestion] = useState(null) // A leveraged position on Noether. Its own shape again: no route, no @@ -764,6 +809,7 @@ export function IntentChat(): JSX.Element { /** What a previous attempt left on screen, which a new turn must not inherit. */ function resetTurnState(): void { + setChainRace(null) sequence.reset() swap.reset() supply.reset() @@ -1489,15 +1535,21 @@ export function IntentChat(): JSX.Element { // The thread only renders once there is a message, so without this the // card below would be built and never seen. setMessage(text) - chainAction.prepare(action, text) + // The agents race first; Review on a route is what builds it. + chainAction.reset() + setExecutingKey(null) + setDirectFallback(false) + setChainRace({ request: action, text }) return } if (looksLikeChainAction(text)) { setMessage(text) + setChainRace(null) chainAction.refuse(chainHelp(descriptor.name, capabilities), text) return } chainAction.reset() + setChainRace(null) } // An answer to a question already asked. @@ -1704,6 +1756,7 @@ export function IntentChat(): JSX.Element { cashout.dismiss() setCashoutQuestion(null) setParsed(null) + setChainRace(null) setExecutingKey(null) setPlacedId(null) setAffordError(null) @@ -1751,6 +1804,26 @@ export function IntentChat(): JSX.Element { // A signature in flight is the real reason to refuse: past that point a // transaction is with the wallet or the network, and switching routes // would leave the wrong one submitted. + // A race on another chain signs through that chain's own card, never the + // Stellar swap hook. + if (slug !== 'stellar' && chainRace !== null) { + const outcome = reviewChainRoute({ + // Only this agent's own route: falling back to the winner's would sign + // a trade the user did not pick. + route: routesByAgent[key], + asked: chainRace.request, + text: chainRace.text, + phase: chainAction.phase, + prepare: chainAction.prepare, + refuse: chainAction.refuse, + }) + if (outcome === 'prepared') { + setDirectFallback(false) + setExecutingKey(key) + } + return + } + const inFlight = swap.phase === 'signing' || swap.phase === 'submitting' || swap.phase === 'settled' if (!parsed || inFlight) return @@ -2384,8 +2457,13 @@ export function IntentChat(): JSX.Element { locked={ swap.phase === 'signing' || swap.phase === 'submitting' || - swap.phase === 'settled' + swap.phase === 'settled' || + chainAction.phase === 'signing' || + chainAction.phase === 'submitting' || + chainAction.phase === 'settled' || + chainAction.phase === 'pending' } + {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once the agents have answered and before anything is signed. */} @@ -2560,6 +2638,11 @@ export function IntentChat(): JSX.Element { one signature — no sequence to explain. */} {supply.phase !== 'idle' ? : null} + {directFallback && chainAction.phase !== 'idle' ? ( +

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

+ ) : null} {chainAction.phase !== 'idle' ? : null} {cashout.session !== null ? ( diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts index d1192678..d40d6114 100644 --- a/apps/dapp/hooks/use-competition.ts +++ b/apps/dapp/hooks/use-competition.ts @@ -11,6 +11,8 @@ import type { } from '../lib/agents/competition' import { DECIDE_AT, RACE_DURATION, WINDOW_SECONDS, revealFloor } from '../lib/agents/competition' import { decodeFrame } from '../lib/agents/events' +import type { ChainActionRequest } from '../lib/chains/types' +import { chainFigures } from '../lib/agents/chain-race' import { setAttributedRace } from '../lib/api/network-fetch' import { planDecision, planReveal } from '../lib/agents/pacing' import type { ParsedIntent } from '../lib/parse-intent' @@ -63,7 +65,14 @@ export function useCompetition( * sent as `undefined`, so a disconnected caller is counted by IP only, the * same as before this existed. */ - account?: string + account?: string, + /** + * An action on a chain other than Stellar, as the user typed it. Runs the + * race in place of `parsed`, which stays null there so Stellar's cards do not + * appear. The request is sent as structured data: agents pick a route and + * never supply the amount or tokens. + */ + chainRace?: { request: ChainActionRequest; text: string } ): CompetitionWithRoute { // The effect reads the latest context through a ref and restarts on its // content, not on the array's identity, which is new every render. @@ -84,13 +93,15 @@ export function useCompetition( const [route, setRoute] = useState(undefined) const [routesByAgent, setRoutesByAgent] = useState>({}) const [agents, setAgents] = useState([]) + const [routeCount, setRouteCount] = useState(undefined) // The same list, readable inside the stream loop's closures without a // stale render's copy. const agentsRef = useRef([]) const lastRevealRef = useRef(0) useEffect(() => { - if (parsed === null) { + if (parsed === null && chainRace === undefined) { + setRouteCount(undefined) setProposals({}) setRevealed({}) setPhase('idle') @@ -174,6 +185,7 @@ export function useCompetition( setRoute(undefined) setRoutesByAgent({}) setAgents([]) + setRouteCount(undefined) agentsRef.current = [] // A stream that stalls without erroring would leave the panel waiting on @@ -206,7 +218,8 @@ export function useCompetition( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - text: parsed?.outcome ?? '', + text: chainRace?.text ?? parsed?.outcome ?? '', + ...(chainRace !== undefined ? { chainAction: chainRace.request } : {}), ...(parsed?.reading !== undefined ? { reading: parsed.reading } : {}), chain, ...(contextRef.current.length > 0 ? { context: contextRef.current } : {}), @@ -253,6 +266,7 @@ export function useCompetition( agentsRef.current = frame.agents setAttributedRace(frame.competitionId) setAgents(frame.agents) + setRouteCount(frame.routeCount) continue } @@ -272,6 +286,7 @@ export function useCompetition( if (frame.route !== undefined) { setRoutesByAgent((prev) => ({ ...prev, [key]: frame.route })) } + const figures = chainFigures(frame.route) const source = (frame.route as { source?: unknown } | undefined)?.source setProposals((prev) => ({ @@ -287,6 +302,7 @@ export function useCompetition( score: 0, reasoning: frame.proposal.reasoning, ...(typeof source === 'string' ? { source } : {}), + ...(figures !== undefined ? { figures } : {}), sliceCount: frame.proposal.sliceCount, horizonMinutes: frame.proposal.horizonMinutes, executionMode: frame.proposal.executionMode, @@ -362,7 +378,7 @@ export function useCompetition( for (const t of timeouts) clearTimeout(t) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [parsed, chain, contextKey]) + }, [parsed, chain, contextKey, chainRace]) return { proposals, @@ -373,6 +389,7 @@ export function useCompetition( winner, unanimous, routesByAgent, + ...(routeCount !== undefined ? { routeCount } : {}), ...(error !== undefined ? { error } : {}), ...(route !== undefined ? { route } : {}), } diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index 29158785..b50c73e1 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -88,6 +88,7 @@ describe('POST /api/agents/compete on a chain', () => { ) ) expect(out[0]?.type).toBe('competition:started') + expect(out[0]?.routeCount).toBe(1) expect(state.propose).toHaveBeenCalledTimes(1) const given = (state.propose as any).mock.calls[0][0] expect(given.market.routes).toHaveLength(1) @@ -110,6 +111,7 @@ describe('POST /api/agents/compete on a chain', () => { ) expect(out[0]?.type).toBe('competition:started') const given = (state.propose as any).mock.calls[0][0] + expect(out[0]?.routeCount).toBeUndefined() expect(given.intent.input).toMatchObject({ tokenIn: 'XLM', tokenOut: 'USDC' }) }) @@ -126,7 +128,7 @@ describe('POST /api/agents/compete on a chain', () => { ) ) expect(out).toHaveLength(1) - expect(out[0]?.type).toBe('competition:error') + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'action_refused' }) expect(out[0]?.message).toContain("I can't confirm SCAM as a verified token") expect(state.propose).not.toHaveBeenCalled() }) @@ -142,7 +144,7 @@ describe('POST /api/agents/compete on a chain', () => { ) ) expect(out).toHaveLength(1) - expect(out[0]).toMatchObject({ type: 'competition:error' }) + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'chain_unsupported' }) expect(out[0]?.message).toContain('Nothing was signed or sent') expect(state.propose).not.toHaveBeenCalled() }) @@ -177,7 +179,7 @@ describe('POST /api/agents/compete on a chain', () => { ) ) expect(out).toHaveLength(1) - expect(out[0]?.type).toBe('competition:error') + expect(out[0]).toMatchObject({ type: 'competition:error', code: 'action_refused' }) expect(out[0]?.message).toContain('Nothing was signed or sent') expect(quote).not.toHaveBeenCalled() expect(state.propose).not.toHaveBeenCalled() diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts new file mode 100644 index 00000000..ec268109 --- /dev/null +++ b/apps/dapp/lib/__tests__/agents-chain-race.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + chainFigures, + chainRequestFor, + needsDirectRoute, + reviewChainRoute, + singleRouteNote, +} from '../agents/chain-race' +import type { ChainRouteQuote } from '../agents/chain-routes' +import type { ChainActionRequest } from '../chains/types' + +const asked: ChainActionRequest = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } + +function route(over: Partial = {}, request: ChainActionRequest = asked) { + return { + kind: 'chain', + request, + venueId: 'ref', + venueName: 'Ref', + pay: { amount: request.amount, symbol: request.tokenIn }, + ...over, + } as ChainRouteQuote +} + +describe('chainRequestFor', () => { + it('returns the asked request with only the venue set from the route', () => { + expect(chainRequestFor(route(), { ...asked, venue: 'refin' })).toEqual({ + ...asked, + venue: 'ref', + }) + }) + + it('refuses a route whose amount differs', () => { + expect(chainRequestFor(route({}, { ...asked, amount: '6' }), asked)).toBeUndefined() + }) + + it('refuses a route whose tokenIn differs', () => { + expect(chainRequestFor(route({}, { ...asked, tokenIn: 'USDT' }), asked)).toBeUndefined() + }) + + it('refuses a route whose tokenOut differs', () => { + expect(chainRequestFor(route({}, { ...asked, tokenOut: 'SCAM' }), asked)).toBeUndefined() + }) + + it('refuses a route whose kind differs', () => { + expect(chainRequestFor(route({}, { ...asked, kind: 'stake' }), asked)).toBeUndefined() + }) + + it('refuses a Stellar swap quote', () => { + const stellar = { id: 'x', source: 'SDEX', sendAmount: '5', receiveAmount: '1', path: [] } + expect(chainRequestFor(stellar, asked)).toBeUndefined() + expect(chainRequestFor(undefined, asked)).toBeUndefined() + }) + + it('refuses a route with no venue id', () => { + expect(chainRequestFor(route({ venueId: '' }), asked)).toBeUndefined() + }) +}) + +describe('singleRouteNote', () => { + it('says the exact sentence for one route', () => { + expect(singleRouteNote(1, 'NEAR')).toBe( + "One route available on NEAR. The agents are judging whether it's worth taking." + ) + }) + + it('says nothing otherwise', () => { + expect(singleRouteNote(2, 'NEAR')).toBeUndefined() + expect(singleRouteNote(0, 'NEAR')).toBeUndefined() + expect(singleRouteNote(undefined, 'NEAR')).toBeUndefined() + }) +}) + +describe('reviewChainRoute', () => { + const base = () => ({ + route: route(), + asked, + text: 'swap 5 NEAR to USDC', + phase: 'idle' as const, + prepare: vi.fn(), + refuse: vi.fn(), + }) + + it('prepares the asked request with the route venue', () => { + const args = base() + expect(reviewChainRoute(args)).toBe('prepared') + expect(args.prepare).toHaveBeenCalledWith({ ...asked, venue: 'ref' }, args.text) + }) + + it('does nothing while a prepare is under way', () => { + const args = { ...base(), phase: 'building' as const } + expect(reviewChainRoute(args)).toBe('ignored') + expect(args.prepare).not.toHaveBeenCalled() + expect(args.refuse).not.toHaveBeenCalled() + }) + + it('may replace a built, unsigned card', () => { + const args = { ...base(), phase: 'review' as const } + expect(reviewChainRoute(args)).toBe('prepared') + }) + + it.each(['signing', 'submitting', 'settled', 'pending'] as const)( + 'does nothing once %s', + (phase) => { + const args = { ...base(), phase } + expect(reviewChainRoute(args)).toBe('ignored') + expect(args.prepare).not.toHaveBeenCalled() + } + ) + + it('refuses in plain words when the route is not what was asked', () => { + const args = { ...base(), route: route({}, { ...asked, amount: '9' }) } + expect(reviewChainRoute(args)).toBe('refused') + expect(args.prepare).not.toHaveBeenCalled() + expect(args.refuse).toHaveBeenCalledWith( + expect.stringContaining('Nothing was signed'), + args.text + ) + }) +}) + +describe('needsDirectRoute', () => { + const ended = { phase: 'decided' as const, winner: null, anyAnswered: false } + + it('falls back when the race ends in an offline or unsupported error', () => { + expect(needsDirectRoute({ ...ended, error: { code: 'agents_offline' } })).toBe(true) + expect(needsDirectRoute({ ...ended, error: { code: 'chain_unsupported' } })).toBe(true) + expect(needsDirectRoute({ ...ended, error: { code: 'no_agent_answered' } })).toBe(true) + }) + + it('does not fall back from a refusal the user must read', () => { + expect(needsDirectRoute({ ...ended, error: { code: 'action_refused' } })).toBe(false) + }) + + it('does not fall back while the race is running or after a winner', () => { + expect( + needsDirectRoute({ ...ended, phase: 'competing', error: { code: 'agents_offline' } }) + ).toBe(false) + expect(needsDirectRoute({ phase: 'decided', winner: 'a', anyAnswered: true })).toBe(false) + }) + + it('falls back when the stream ended with nobody answering and no error', () => { + expect(needsDirectRoute(ended)).toBe(true) + }) +}) + +describe('chainFigures', () => { + it('says the price is unknown for a swap with no measured value', () => { + expect(chainFigures(route())).toEqual({ headline: 'Price unknown' }) + }) + + it('leaves a priced swap to the usual display', () => { + expect(chainFigures(route({ valueInUsd: 20, valueOutUsd: 19.9 }))).toBeUndefined() + }) + + it('shows the rate for a supply that has one', () => { + const supply: ChainActionRequest = { kind: 'supply', amount: '5', tokenIn: 'USDC' } + expect(chainFigures(route({ apyPercent: 4.25 }, supply))).toEqual({ headline: '4.25% APY' }) + }) + + it('says the rate is not available otherwise', () => { + const stake: ChainActionRequest = { kind: 'stake', amount: '5', tokenIn: 'NEAR' } + expect(chainFigures(route({}, stake))).toEqual({ headline: 'Rate not available' }) + }) + + it('does not touch a Stellar route', () => { + expect(chainFigures({ id: 'x', source: 'SDEX' })).toBeUndefined() + expect(chainFigures(undefined)).toBeUndefined() + }) +}) diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts new file mode 100644 index 00000000..c9bfaccb --- /dev/null +++ b/apps/dapp/lib/agents/chain-race.ts @@ -0,0 +1,132 @@ +import type { ChainActionRequest } from '../chains/types' +import type { CompetitionErrorCode } from './competition' +import type { ChainRouteQuote } from './chain-routes' +import { isUnmeasuredSwap } from './settle-chain-plan' + +/** + * The route as a chain route, or nothing. A Stellar quote has no `kind`, so it + * can never be mistaken for one and signed through the chain path. + */ +function asChainRoute(route: unknown): ChainRouteQuote | undefined { + if (typeof route !== 'object' || route === null) return undefined + const r = route as Partial + if (r.kind !== 'chain' || typeof r.request !== 'object' || r.request === null) return undefined + if (typeof r.venueId !== 'string' || r.venueId === '') return undefined + return r as ChainRouteQuote +} + +/** + * What to prepare when the user presses Review on a route. + * + * Agents only ever choose a route; the amount and tokens are what the user + * typed. So the request that gets signed is the asked one with the venue taken + * from the route, and a route that disagrees with the ask in kind, amount or + * either token is refused rather than corrected. + */ +export function chainRequestFor( + route: unknown, + asked: ChainActionRequest +): ChainActionRequest | undefined { + const chain = asChainRoute(route) + if (chain === undefined) return undefined + const r = chain.request + if ( + r.kind !== asked.kind || + r.amount !== asked.amount || + r.tokenIn !== asked.tokenIn || + (r.tokenOut ?? '') !== (asked.tokenOut ?? '') + ) { + return undefined + } + return { ...asked, venue: chain.venueId } +} + +/** Said when only one route exists, so the race does not look like it skipped the choosing. */ +export function singleRouteNote( + routeCount: number | undefined, + chainName: string +): string | undefined { + if (routeCount !== 1) return undefined + return `One route available on ${chainName}. The agents are judging whether it's worth taking.` +} + +type ChainActionPhaseName = + | 'idle' + | 'building' + | 'review' + | 'signing' + | 'submitting' + | 'settled' + | 'pending' + | 'failed' + +export type ReviewOutcome = 'prepared' | 'refused' | 'ignored' + +/** + * Pressing Review on a chain route. + * + * A press while a build is under way does nothing, so one press makes one + * build (and one deposit address). Once a signature is with the wallet or the network + * the route can no longer change. A built card that is not yet signed may be + * replaced by choosing another route. + */ +export function reviewChainRoute(args: { + route: unknown + asked: ChainActionRequest + text: string + phase: ChainActionPhaseName + prepare: (request: ChainActionRequest, asked: string) => void + refuse: (message: string, asked: string) => void +}): ReviewOutcome { + const { phase } = args + if (phase === 'building') return 'ignored' + if (phase === 'signing' || phase === 'submitting' || phase === 'settled' || phase === 'pending') { + return 'ignored' + } + + const request = chainRequestFor(args.route, args.asked) + if (request === undefined) { + args.refuse( + "That route doesn't match what you asked for, so it can't be used. Nothing was signed or sent.", + args.text + ) + return 'refused' + } + args.prepare(request, args.text) + return 'prepared' +} + +/** + * Whether a finished race left the user with nothing to review, so the direct + * route should be built instead. A refusal (unknown token, bad amount, over + * the cap) is not that: it is shown as written, because building would only + * refuse again. + */ +export function needsDirectRoute(state: { + phase: string + winner: string | null + anyAnswered: boolean + error?: { code: CompetitionErrorCode } +}): boolean { + if (state.phase !== 'decided') return false + if (state.error !== undefined) return state.error.code !== 'action_refused' + return state.winner === null && !state.anyAnswered +} + +/** + * How a chain route's figures read when the usual "$ avg, % vs oracle" would + * mislead: a swap nobody could price shows as zero, which reads as perfect, + * and a supply or stake has a rate and not a price. Undefined means the usual + * display is right, which includes every Stellar route. + */ +export function chainFigures(route: unknown): { headline: string } | undefined { + const chain = asChainRoute(route) + if (chain === undefined) return undefined + if (chain.request.kind === 'swap') { + return isUnmeasuredSwap(chain) ? { headline: 'Price unknown' } : undefined + } + const apy = chain.apyPercent + return apy !== undefined && Number.isFinite(apy) + ? { headline: `${apy}% APY` } + : { headline: 'Rate not available' } +} diff --git a/apps/dapp/lib/agents/competition.ts b/apps/dapp/lib/agents/competition.ts index 2586c9e5..949ad91a 100644 --- a/apps/dapp/lib/agents/competition.ts +++ b/apps/dapp/lib/agents/competition.ts @@ -18,7 +18,12 @@ import { agentGradient } from './identity' export type CompetitionPhase = 'idle' | 'competing' | 'decided' /** Why a competition could not run at all, as opposed to one agent failing. */ -export type CompetitionErrorCode = 'agents_offline' | 'chain_unsupported' | 'no_agent_answered' +export type CompetitionErrorCode = + | 'agents_offline' + | 'chain_unsupported' + | 'no_agent_answered' + /** The action itself was refused (unknown token, bad amount, over the cap); shown as written. */ + | 'action_refused' export interface CompetitionError { code: CompetitionErrorCode @@ -59,6 +64,11 @@ export interface AgentProposalView { * Empty or absent for a plan that did nothing the brief warns against. */ penalties?: { reason: string; points: number; detail: string }[] + /** + * What to show in place of the dollar price and oracle gap, when those would + * mislead: a chain swap that could not be priced, or a rate on a supply. + */ + figures?: { headline: string } /** Set when the agent produced no proposal: timed out, refused, or was rejected. */ failed?: BrainErrorCode } @@ -83,6 +93,8 @@ export interface CompetitionState { unanimous?: boolean /** Present when the competition could not run, in place of proposals. */ error?: CompetitionError + /** How many routes the agents are choosing between. Sent only for chains other than Stellar. */ + routeCount?: number } /** diff --git a/apps/dapp/lib/agents/events.ts b/apps/dapp/lib/agents/events.ts index dc4fd0b2..c51793fb 100644 --- a/apps/dapp/lib/agents/events.ts +++ b/apps/dapp/lib/agents/events.ts @@ -23,6 +23,8 @@ export interface CompetitionStartedFrame { */ agents: { key: AgentKey; name: string; gradient: string; model: string }[] windowSeconds: number + /** How many routes the agents choose between. Sent for chains other than Stellar only. */ + routeCount?: number } export interface CompetitionProposalFrame { @@ -92,7 +94,7 @@ export interface CompetitionWinnerFrame { */ export interface CompetitionErrorFrame { type: 'competition:error' - code: 'agents_offline' | 'chain_unsupported' | 'no_agent_answered' + code: 'agents_offline' | 'chain_unsupported' | 'no_agent_answered' | 'action_refused' message: string } diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index df6dcd92..01e9039a 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -7,8 +7,21 @@ * and the prompt forbids the rest. */ +/** + * Chains where agents race to propose the route. A chain joins this list only + * once its venues can be quoted, so the sheet never promises a race that does + * not run. + */ +const AGENT_RACE_CHAINS = ['stellar', 'solana', 'sui', 'near'] + +const SWAP_RACED = + 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.' +const SWAP_RACED_OFF_STELLAR = + 'Swap: trade one asset for another at the market price. Agents compete to propose the best route; you review it and sign in your wallet.' +const SWAP_DIRECT = + 'Swap: trade one asset for another at the market price. You review it and sign in your wallet.' + const COMMON = [ - 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.', 'Accumulate: spread the buying of an asset over time instead of all at once.', 'Hedge or rebalance: move exposure from one asset into another.', 'Send: send an asset such as USDC or XLM to another account or to a name like deon.xlm. You review it and sign in your wallet.', @@ -24,6 +37,14 @@ const STELLAR_ONLY = [ /** The facts about Intent that apply on a chain, one per line. */ export function capabilitySheet(chain: string): string { - const lines = chain === 'stellar' ? [...COMMON, ...STELLAR_ONLY] : COMMON + // Limit orders are Stellar's; elsewhere a swap is at the market price. + const swap = + chain === 'stellar' + ? SWAP_RACED + : AGENT_RACE_CHAINS.includes(chain) + ? SWAP_RACED_OFF_STELLAR + : SWAP_DIRECT + const common = [swap, ...COMMON] + const lines = chain === 'stellar' ? [...common, ...STELLAR_ONLY] : common return lines.map((l) => `- ${l}`).join('\n') } From bac07c1388b11d2c1836ff1f0f642ec9830f2e78 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:47:55 +0100 Subject: [PATCH 08/24] fix(dapp): keep chain routes off the swap hook and close the race's stale paths A chain route is never given to the Stellar swap hook, which threw on it and would have built a second signable card. A superseded build is ignored, a race ends on cash-out, history and reset, and Review stays closed once a signature was given. Copy and figures now say only what is true. --- .../components/intents/competition-panel.tsx | 7 +- apps/dapp/components/intents/intent-chat.tsx | 94 ++++++++++++--- apps/dapp/hooks/use-chain-action.ts | 27 ++++- apps/dapp/hooks/use-competition.ts | 4 +- .../lib/__tests__/agents-chain-race.test.ts | 110 ++++++++++++++++++ .../orchestrate-capabilities-chain.test.ts | 26 +++++ apps/dapp/lib/agents/chain-race.ts | 72 +++++++++++- apps/dapp/lib/orchestrate/capabilities.ts | 26 ++++- 8 files changed, 335 insertions(+), 31 deletions(-) create mode 100644 apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index ed3001df..9cb8915e 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -86,6 +86,7 @@ export function CompetitionPanel({ executingKey, locked = false, chainName, + directRoute = false, }: { state: CompetitionState onExecute: (key: string) => void @@ -99,6 +100,8 @@ export function CompetitionPanel({ locked?: boolean /** The chain's name, set off Stellar so a single route can be explained. */ chainName?: string + /** The direct route is being offered instead, so the error must not say agents can't act here. */ + directRoute?: boolean }): JSX.Element | null { const { proposals, revealed, agents, phase, winner, error } = state const unanimous = state.unanimous === true @@ -121,7 +124,9 @@ export function CompetitionPanel({

{error.code === 'chain_unsupported' - ? 'Agents cannot execute here' + ? directRoute + ? 'The agents did not race' + : 'Agents cannot execute here' : error.code === 'no_agent_answered' ? 'No agent answered' : 'Agents are not online'} diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index d9926212..dcfbb616 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -24,7 +24,16 @@ import { StrategyEditor, type StrategyEdits } from './strategy-editor' import { DEFAULT_SLIPPAGE_BPS } from '../../lib/swap/build-tx' import { ACKNOWLEDGED } from '../../lib/chat/clarify-flow' import type { ClarifyTurn } from '../../lib/clarify/clarify' -import { needsDirectRoute, reviewChainRoute } from '../../lib/agents/chain-race' +import { + chainReviewClosed, + chainSigning, + isChainRoute, + needsDirectRoute, + reviewChainRoute, + routeForSwap, + selectionHolds, + showDirectNote, +} from '../../lib/agents/chain-race' import { agentsFromProposals } from '../../lib/agents/competition' import type { CreateIntentInput } from '@intent/types' @@ -451,7 +460,9 @@ export function IntentChat(): JSX.Element { const winnerRoute = restored !== null ? undefined : live.route const chosenRoute = useMemo(() => { if (executingKey === null) return undefined - return routesByAgent[executingKey] ?? winnerRoute + // A chain route never reaches the Stellar swap hook: it has no sendAmount, + // and would be read as a Stellar quote. + return routeForSwap(routesByAgent[executingKey] ?? winnerRoute) }, [executingKey, routesByAgent, winnerRoute]) const swap = useSwapExecution(chosenRoute, strategy.slippageBps) @@ -469,8 +480,21 @@ export function IntentChat(): JSX.Element { const chainAction = useChainAction() // A new race gets a clean slate when the chain changes. useEffect(() => { - setChainRace(null) + clearChainRace() + chainAction.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]) + // Selected is shown only while a build or its aftermath is live; a failed + // build must not leave an agent looking chosen. + useEffect(() => { + if (chainRace !== null && !selectionHolds(chainAction.phase)) setExecutingKey(null) + }, [chainRace, chainAction.phase]) + const chainConfirmRef = useRef(null) + const chainAwaiting = chainAction.phase === 'review' + useEffect(() => { + if (!chainAwaiting) return + chainConfirmRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [chainAwaiting]) // The race ended with nothing to review: build the direct route once, so the // chain keeps working when the agents are offline. A refusal of the action // is shown as written by the card and is not retried. @@ -770,7 +794,8 @@ export function IntentChat(): JSX.Element { swap.phase === 'signing' || swap.phase === 'submitting' || sequence.phase === 'signing' || - sequence.phase === 'submitting' + sequence.phase === 'submitting' || + chainSigning(chainAction.phase) /** Something came of the open intent: a trade settled, or an order was placed. */ const turnExecuted = swap.phase === 'settled' || sequence.phase === 'settled' || placedId !== null @@ -807,9 +832,15 @@ export function IntentChat(): JSX.Element { setNotes([]) } + /** Ends a chain race and what hangs off it; the direct-route note belongs to the race. */ + function clearChainRace(): void { + setChainRace(null) + setDirectFallback(false) + } + /** What a previous attempt left on screen, which a new turn must not inherit. */ function resetTurnState(): void { - setChainRace(null) + clearChainRace() sequence.reset() swap.reset() supply.reset() @@ -1372,6 +1403,13 @@ export function IntentChat(): JSX.Element { return } setMessage(text) + // A chain race in progress ends here; resetting the card also drops any + // build still in flight, so it cannot land over the cash-out. + if (chainRace !== null) { + clearChainRace() + chainAction.reset() + setExecutingKey(null) + } setRestored(null) setHistoryOpen(false) setTurnId(null) @@ -1496,6 +1534,12 @@ export function IntentChat(): JSX.Element { // A misspelled verb is corrected before anything reads the sentence. const text = fixVerbTypo(typed) + // A chain signature is with the wallet: a new message must not close its card. + if (chainSigning(chainAction.phase)) { + setAffordError('Finish or cancel the signature first, then continue the chat.') + return + } + const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet()) if (cashoutRoute.kind !== 'pass') { startCashout(cashoutRoute, text) @@ -1535,21 +1579,25 @@ export function IntentChat(): JSX.Element { // The thread only renders once there is a message, so without this the // card below would be built and never seen. setMessage(text) + // 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. chainAction.reset() - setExecutingKey(null) - setDirectFallback(false) setChainRace({ request: action, text }) return } if (looksLikeChainAction(text)) { setMessage(text) - setChainRace(null) + clearChainRace() chainAction.refuse(chainHelp(descriptor.name, capabilities), text) return } chainAction.reset() - setChainRace(null) + clearChainRace() } // An answer to a question already asked. @@ -1751,12 +1799,15 @@ export function IntentChat(): JSX.Element { } function handleReset(): void { + // The card holds a signature in flight; closing it would orphan the transaction. + if (chainSigning(chainAction.phase)) return + if (slug !== 'stellar') chainAction.reset() // The thread stays while a cash-out is under way: it carries the warning not to resend. setMessage(cashout.locked ? message : null) cashout.dismiss() setCashoutQuestion(null) setParsed(null) - setChainRace(null) + clearChainRace() setExecutingKey(null) setPlacedId(null) setAffordError(null) @@ -1814,6 +1865,7 @@ export function IntentChat(): JSX.Element { asked: chainRace.request, text: chainRace.text, phase: chainAction.phase, + signed: chainAction.signed === true, prepare: chainAction.prepare, refuse: chainAction.refuse, }) @@ -1823,6 +1875,8 @@ export function IntentChat(): JSX.Element { } return } + // A chain route with no race open (a reopened turn) is not for the Stellar path. + if (isChainRoute(routesByAgent[key] ?? winnerRoute)) return const inFlight = swap.phase === 'signing' || swap.phase === 'submitting' || swap.phase === 'settled' @@ -2218,6 +2272,12 @@ export function IntentChat(): JSX.Element { { + if (chainSigning(chainAction.phase)) return + // A race on screen ends when another turn is opened. + if (chainRace !== null) { + clearChainRace() + chainAction.reset() + } // Reopen exactly what was recorded. This used to resubmit the // text, which started a fresh competition and produced different // answers — the conversation was not being restored at all. @@ -2458,11 +2518,9 @@ export function IntentChat(): JSX.Element { swap.phase === 'signing' || swap.phase === 'submitting' || swap.phase === 'settled' || - chainAction.phase === 'signing' || - chainAction.phase === 'submitting' || - chainAction.phase === 'settled' || - chainAction.phase === 'pending' + chainReviewClosed(chainAction.phase, chainAction.signed === true) } + directRoute={directFallback} {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once @@ -2638,12 +2696,16 @@ export function IntentChat(): JSX.Element { one signature — no sequence to explain. */} {supply.phase !== 'idle' ? : null} - {directFallback && chainAction.phase !== 'idle' ? ( + {showDirectNote(directFallback, chainAction.phase) ? (

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

) : null} - {chainAction.phase !== 'idle' ? : null} + {chainAction.phase !== 'idle' ? ( +
+ +
+ ) : null} {cashout.session !== null ? ( diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index 4d623aa2..10b9e3c7 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import type { ChainActionRequest, PreparedAction, SubmitResult } from '../lib/chains/types' import { useChain } from '../providers/chain-provider' @@ -24,6 +24,8 @@ export interface ChainActionState { hash?: string explorerUrl?: string error?: string + /** A signature was given for this action, so it must not be signed again. */ + signed?: boolean } export interface ChainAction extends ChainActionState { @@ -41,10 +43,18 @@ export function useChainAction(): ChainAction { const { slug, adapter } = useChain() const { address } = useWallet() const [state, setState] = useState(EMPTY) - - const reset = useCallback(() => setState(EMPTY), []) + // Each prepare, refuse and reset starts a new generation. A response from an + // older one is dropped, so a build that was superseded cannot become a + // signable card under a newer request. + const generation = useRef(0) + + const reset = useCallback(() => { + generation.current += 1 + setState(EMPTY) + }, []) const refuse = useCallback((message: string, asked: string) => { + generation.current += 1 setState({ phase: 'failed', asked, error: message }) }, []) @@ -55,6 +65,8 @@ export function useChainAction(): ChainAction { return } const account = address + generation.current += 1 + const mine = generation.current async function run(): Promise { setState({ phase: 'building', asked }) @@ -64,6 +76,7 @@ export function useChainAction(): ChainAction { body: JSON.stringify({ account, request }), }) const body = (await res.json()) as { prepared?: PreparedAction; error?: string } + if (generation.current !== mine) return if (body.prepared === undefined) { setState({ phase: 'failed', @@ -76,6 +89,7 @@ export function useChainAction(): ChainAction { } void run().catch(() => { + if (generation.current !== mine) return setState({ phase: 'failed', asked, error: 'Could not reach the network.' }) }) }, @@ -86,11 +100,14 @@ export function useChainAction(): ChainAction { const prepared = state.prepared if (prepared === undefined || address === undefined) return const account = address + const mine = generation.current + const current = (): boolean => generation.current === mine async function run(prepared: PreparedAction): Promise { setState((s) => ({ ...s, phase: 'signing' })) const outcome = await adapter.signPrepared?.(prepared.signing) + if (!current()) return if (outcome === undefined) { setState((s) => ({ ...s, phase: 'failed', error: 'This wallet cannot sign here yet.' })) return @@ -109,7 +126,7 @@ export function useChainAction(): ChainAction { return } - setState((s) => ({ ...s, phase: 'submitting' })) + setState((s) => ({ ...s, phase: 'submitting', signed: true })) const res = await fetch(`/api/chain/${slug}/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -121,6 +138,7 @@ export function useChainAction(): ChainAction { }), }) const result = (await res.json()) as SubmitResult + if (!current()) return if (!result.ok) { setState((s) => ({ ...s, @@ -138,6 +156,7 @@ export function useChainAction(): ChainAction { } void run(prepared).catch(() => { + if (!current()) return setState((s) => ({ ...s, phase: 'failed', diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts index d40d6114..09a27ee9 100644 --- a/apps/dapp/hooks/use-competition.ts +++ b/apps/dapp/hooks/use-competition.ts @@ -12,7 +12,7 @@ import type { import { DECIDE_AT, RACE_DURATION, WINDOW_SECONDS, revealFloor } from '../lib/agents/competition' import { decodeFrame } from '../lib/agents/events' import type { ChainActionRequest } from '../lib/chains/types' -import { chainFigures } from '../lib/agents/chain-race' +import { routeFigures } from '../lib/agents/chain-race' import { setAttributedRace } from '../lib/api/network-fetch' import { planDecision, planReveal } from '../lib/agents/pacing' import type { ParsedIntent } from '../lib/parse-intent' @@ -286,7 +286,7 @@ export function useCompetition( if (frame.route !== undefined) { setRoutesByAgent((prev) => ({ ...prev, [key]: frame.route })) } - const figures = chainFigures(frame.route) + const figures = routeFigures(frame.route, chainRace?.request) const source = (frame.route as { source?: unknown } | undefined)?.source setProposals((prev) => ({ diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts index ec268109..1bc92d50 100644 --- a/apps/dapp/lib/__tests__/agents-chain-race.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-race.test.ts @@ -3,8 +3,15 @@ import { describe, expect, it, vi } from 'vitest' import { chainFigures, chainRequestFor, + chainReviewClosed, + chainSigning, + isChainRoute, needsDirectRoute, reviewChainRoute, + routeForSwap, + routeFigures, + selectionHolds, + showDirectNote, singleRouteNote, } from '../agents/chain-race' import type { ChainRouteQuote } from '../agents/chain-routes' @@ -169,3 +176,106 @@ describe('chainFigures', () => { expect(chainFigures(undefined)).toBeUndefined() }) }) + +describe('routeForSwap', () => { + it('keeps every chain route away from the Stellar swap hook', () => { + expect(routeForSwap(route())).toBeUndefined() + expect(routeForSwap({ kind: 'chain' })).toBeUndefined() + expect(isChainRoute({ kind: 'chain', venueId: 5 })).toBe(true) + }) + + it('passes a Stellar quote and nothing through unchanged', () => { + const stellar = { id: 'x', sendAmount: '5000000' } + expect(routeForSwap(stellar)).toBe(stellar) + expect(routeForSwap(undefined)).toBeUndefined() + }) +}) + +describe('chain card phases', () => { + it('closes Review once a signature is out, given, or the outcome is unknown', () => { + expect(chainReviewClosed('signing', false)).toBe(true) + expect(chainReviewClosed('submitting', false)).toBe(true) + expect(chainReviewClosed('settled', false)).toBe(true) + expect(chainReviewClosed('pending', false)).toBe(true) + expect(chainReviewClosed('failed', true)).toBe(true) + expect(chainReviewClosed('review', false)).toBe(false) + expect(chainReviewClosed('failed', false)).toBe(false) + expect(chainReviewClosed('idle', false)).toBe(false) + }) + + it('knows when a signature is in flight', () => { + expect(chainSigning('signing')).toBe(true) + expect(chainSigning('submitting')).toBe(true) + expect(chainSigning('review')).toBe(false) + }) + + it('shows the direct-route note only while nothing is signed', () => { + expect(showDirectNote(true, 'building')).toBe(true) + expect(showDirectNote(true, 'review')).toBe(true) + for (const p of ['idle', 'signing', 'submitting', 'settled', 'pending', 'failed'] as const) { + expect(showDirectNote(true, p)).toBe(false) + } + expect(showDirectNote(false, 'review')).toBe(false) + }) + + it('drops the selected look after a failed or empty card', () => { + expect(selectionHolds('failed')).toBe(false) + expect(selectionHolds('idle')).toBe(false) + expect(selectionHolds('building')).toBe(true) + expect(selectionHolds('review')).toBe(true) + expect(selectionHolds('settled')).toBe(true) + }) +}) + +describe('reviewChainRoute after a signature', () => { + const args = (over: object) => ({ + route: route(), + asked, + text: 't', + phase: 'failed' as const, + prepare: vi.fn(), + refuse: vi.fn(), + ...over, + }) + + it('stays closed after a failure that followed a signature', () => { + const a = args({ signed: true }) + expect(reviewChainRoute(a)).toBe('ignored') + expect(a.prepare).not.toHaveBeenCalled() + }) + + it('reopens after a failure before any signature', () => { + expect(reviewChainRoute(args({ signed: false }))).toBe('prepared') + }) + + it('does not wipe a built card for an agent that chose no route', () => { + const a = args({ route: undefined, phase: 'review' }) + expect(reviewChainRoute(a)).toBe('ignored') + expect(a.refuse).not.toHaveBeenCalled() + }) + + it('says the agent chose no route when there is no card to protect', () => { + const a = args({ route: undefined, phase: 'idle' }) + expect(reviewChainRoute(a)).toBe('refused') + expect(a.refuse).toHaveBeenCalledWith(expect.stringContaining("didn't choose a route"), 't') + }) +}) + +describe('routeFigures', () => { + it('shows an unknown price or rate for a chain proposal with no route', () => { + expect(routeFigures(undefined, asked)).toEqual({ headline: 'Price unknown' }) + expect(routeFigures(undefined, { kind: 'supply', amount: '1', tokenIn: 'USDC' })).toEqual({ + headline: 'Rate not available', + }) + }) + + it('changes nothing for Stellar, which asks for no chain request', () => { + expect(routeFigures(undefined, undefined)).toBeUndefined() + expect(routeFigures({ id: 'x' }, undefined)).toBeUndefined() + }) + + it('formats the rate to two places', () => { + const supply: ChainActionRequest = { kind: 'supply', amount: '5', tokenIn: 'USDC' } + expect(chainFigures(route({ apyPercent: 4.2 }, supply))).toEqual({ headline: '4.20% APY' }) + }) +}) diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts new file mode 100644 index 00000000..1604f491 --- /dev/null +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { capabilitySheet } from '../orchestrate/capabilities' + +describe('capabilitySheet per chain', () => { + it('keeps Stellar as it was', () => { + const sheet = capabilitySheet('stellar') + expect(sheet).toContain('limit order') + expect(sheet).toContain('Accumulate') + expect(sheet).toContain('Send:') + }) + + it('says agents compete on NEAR and lists what NEAR can do', () => { + const sheet = capabilitySheet('near') + expect(sheet).toContain('Agents compete') + expect(sheet).toContain('Supply:') + expect(sheet).toContain('Stake:') + expect(sheet).not.toMatch(/Send:|Accumulate|Hedge|limit order/) + }) + + it('does not promise a race on Solana or Sui before their venues can be quoted', () => { + expect(capabilitySheet('solana')).not.toContain('Agents compete') + expect(capabilitySheet('sui')).not.toContain('Agents compete') + expect(capabilitySheet('solana')).not.toContain('Stake:') + }) +}) diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index c9bfaccb..da3ddd64 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -15,6 +15,26 @@ function asChainRoute(route: unknown): ChainRouteQuote | undefined { return r as ChainRouteQuote } +/** + * Whether a route belongs to a chain action. Loose on purpose: anything that + * says it is a chain route must be kept away from the Stellar swap hook, even + * if it is malformed in another way. + */ +export function isChainRoute(route: unknown): boolean { + return ( + typeof route === 'object' && route !== null && (route as { kind?: unknown }).kind === 'chain' + ) +} + +/** + * The route the Stellar swap hook may be given. A chain route has no + * `sendAmount`, and the hook would read it as a Stellar quote, throw, and + * otherwise build a signable Stellar swap beside the chain's own card. + */ +export function routeForSwap(route: unknown): unknown { + return isChainRoute(route) ? undefined : route +} + /** * What to prepare when the user presses Review on a route. * @@ -60,6 +80,31 @@ type ChainActionPhaseName = | 'pending' | 'failed' +/** A signature is with the wallet or the network. */ +export function chainSigning(phase: ChainActionPhaseName): boolean { + return phase === 'signing' || phase === 'submitting' +} + +/** + * Review stays closed once a signature is with the wallet, was given, or the + * outcome is unknown. After a failure that followed a signature the money may + * have moved ("check your wallet's activity"), so a second signature is never + * one press away; a new message starts a new race. + */ +export function chainReviewClosed(phase: ChainActionPhaseName, signed: boolean): boolean { + return signed || chainSigning(phase) || phase === 'settled' || phase === 'pending' +} + +/** The direct-route note is true only while nothing has been signed. */ +export function showDirectNote(direct: boolean, phase: ChainActionPhaseName): boolean { + return direct && (phase === 'building' || phase === 'review') +} + +/** The agent that is shown as selected holds only while a build or its aftermath is live. */ +export function selectionHolds(phase: ChainActionPhaseName): boolean { + return phase !== 'idle' && phase !== 'failed' +} + export type ReviewOutcome = 'prepared' | 'refused' | 'ignored' /** @@ -75,19 +120,23 @@ export function reviewChainRoute(args: { asked: ChainActionRequest text: string phase: ChainActionPhaseName + /** A signature was given for this race. */ + signed?: boolean prepare: (request: ChainActionRequest, asked: string) => void refuse: (message: string, asked: string) => void }): ReviewOutcome { const { phase } = args if (phase === 'building') return 'ignored' - if (phase === 'signing' || phase === 'submitting' || phase === 'settled' || phase === 'pending') { - return 'ignored' - } + if (chainReviewClosed(phase, args.signed === true)) return 'ignored' const request = chainRequestFor(args.route, args.asked) if (request === undefined) { + // A good card already built is not wiped by a click that cannot be used. + if (phase === 'review') return 'ignored' args.refuse( - "That route doesn't match what you asked for, so it can't be used. Nothing was signed or sent.", + isChainRoute(args.route) + ? "That route doesn't match what you asked for, so it can't be used. Nothing was signed or sent." + : "This agent didn't choose a route, so there is nothing to review from it. Nothing was signed or sent.", args.text ) return 'refused' @@ -127,6 +176,19 @@ export function chainFigures(route: unknown): { headline: string } | undefined { } const apy = chain.apyPercent return apy !== undefined && Number.isFinite(apy) - ? { headline: `${apy}% APY` } + ? { headline: `${apy.toFixed(2)}% APY` } : { headline: 'Rate not available' } } + +/** + * The figures for a proposal on a chain race. A proposal with no route has no + * measured price or rate, and the usual zero would read as a perfect fill. + */ +export function routeFigures( + route: unknown, + asked: ChainActionRequest | undefined +): { headline: string } | undefined { + if (route !== undefined) return chainFigures(route) + if (asked === undefined) return undefined + return { headline: asked.kind === 'swap' ? 'Price unknown' : 'Rate not available' } +} diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index 01e9039a..529dbb2a 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -1,3 +1,7 @@ +import type { ChainSlug } from '@intent/types' + +import { chainCapabilities } from '../chains/capabilities' + /** * What Intent does, written down once. * @@ -12,7 +16,7 @@ * once its venues can be quoted, so the sheet never promises a race that does * not run. */ -const AGENT_RACE_CHAINS = ['stellar', 'solana', 'sui', 'near'] +const AGENT_RACE_CHAINS = ['stellar', 'near'] const SWAP_RACED = 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.' @@ -44,7 +48,23 @@ export function capabilitySheet(chain: string): string { : AGENT_RACE_CHAINS.includes(chain) ? SWAP_RACED_OFF_STELLAR : SWAP_DIRECT - const common = [swap, ...COMMON] - const lines = chain === 'stellar' ? [...common, ...STELLAR_ONLY] : common + if (chain === 'stellar') { + return [swap, ...COMMON, ...STELLAR_ONLY].map((l) => `- ${l}`).join('\n') + } + // 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. + const kinds = new Set((chainCapabilities(chain as ChainSlug) ?? []).map((c) => c.kind)) + const raced = AGENT_RACE_CHAINS.includes(chain) + const lines = [swap] + if (kinds.has('supply')) { + lines.push( + `Supply: put an asset you hold into a lending or earn venue to earn yield. ${raced ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` + ) + } + if (kinds.has('stake')) { + lines.push( + `Stake: stake an asset for a staking token. ${raced ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` + ) + } return lines.map((l) => `- ${l}`).join('\n') } From 80e0dacfa2325d970653ce36c722fa8cf7711956 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:57:13 +0100 Subject: [PATCH 09/24] fix(dapp): hold the signed latch for the whole race and check the wallet before signing The latch now outlives the card, so Dismiss and Done no longer reopen Review. It is set before a self-sending wallet is called, and a decline or error there asks the user to check the wallet. The connected account is compared with the one the card was built for, and the panel stops saying nothing was signed once something may have been. --- .../intents/chain-action-confirm.tsx | 6 + .../components/intents/competition-panel.tsx | 7 +- apps/dapp/components/intents/intent-chat.tsx | 6 +- apps/dapp/hooks/use-chain-action.ts | 101 ++++++++---- .../lib/__tests__/chain-signing-flow.test.ts | 145 ++++++++++++++++++ apps/dapp/lib/agents/chain-race.ts | 10 ++ apps/dapp/lib/chains/signing-flow.ts | 74 +++++++++ apps/dapp/lib/weave/cashout-view.ts | 2 +- 8 files changed, 315 insertions(+), 36 deletions(-) create mode 100644 apps/dapp/lib/__tests__/chain-signing-flow.test.ts create mode 100644 apps/dapp/lib/chains/signing-flow.ts diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 9011730c..3f8b6088 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -74,6 +74,12 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele ) : null} + {phase === 'review' && error !== undefined ? ( +

+ {error} +

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

Sent diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index 9cb8915e..3e02f88c 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -5,7 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react' import type { CompetitionState } from '../../lib/agents/competition' -import { singleRouteNote } from '../../lib/agents/chain-race' +import { panelErrorMessage, singleRouteNote } from '../../lib/agents/chain-race' import { describePlan } from '../../lib/agents/competition' import { AgentAvatar } from '../agents/agent-avatar' import { agentBubble, systemPill } from './bubble' @@ -87,6 +87,7 @@ export function CompetitionPanel({ locked = false, chainName, directRoute = false, + signed = false, }: { state: CompetitionState onExecute: (key: string) => void @@ -102,6 +103,8 @@ export function CompetitionPanel({ chainName?: string /** The direct route is being offered instead, so the error must not say agents can't act here. */ directRoute?: boolean + /** Something may have been signed, so "nothing was signed" must not be said. */ + signed?: boolean }): JSX.Element | null { const { proposals, revealed, agents, phase, winner, error } = state const unanimous = state.unanimous === true @@ -131,7 +134,7 @@ export function CompetitionPanel({ ? 'No agent answered' : 'Agents are not online'}

-

{error.message}

+

{panelErrorMessage(error.message, signed)}

) diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index dcfbb616..f69d5bac 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -481,6 +481,7 @@ export function IntentChat(): JSX.Element { // A new race gets a clean slate when the chain changes. useEffect(() => { clearChainRace() + chainAction.release() chainAction.reset() // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug]) @@ -1585,7 +1586,9 @@ export function IntentChat(): JSX.Element { setParsed(null) setTurnId(null) setAffordError(null) - // The agents race first; Review on a route is what builds it. + // The agents race first; Review on a route is what builds it. A new + // race is the one thing that lifts the "already signed" latch. + chainAction.release() chainAction.reset() setChainRace({ request: action, text }) return @@ -2521,6 +2524,7 @@ export function IntentChat(): JSX.Element { chainReviewClosed(chainAction.phase, chainAction.signed === true) } directRoute={directFallback} + signed={chainAction.signed} {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index 10b9e3c7..befa7e34 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -2,7 +2,13 @@ import { useCallback, useRef, useState } from 'react' -import type { ChainActionRequest, PreparedAction, SubmitResult } from '../lib/chains/types' +import type { ChainActionRequest, PreparedAction } from '../lib/chains/types' +import { + accountMismatch, + afterSigning, + afterSubmitting, + latchBeforeSign, +} from '../lib/chains/signing-flow' import { useChain } from '../providers/chain-provider' import { useWallet } from './use-wallet' @@ -24,11 +30,18 @@ export interface ChainActionState { hash?: string explorerUrl?: string error?: string - /** A signature was given for this action, so it must not be signed again. */ - signed?: boolean + /** The account the payload was built and checked for. */ + account?: string } export interface ChainAction extends ChainActionState { + /** + * A signature may have happened in this race, so nothing may be signed again. + * It belongs to the race, not the card: Dismiss, Done and reset leave it set, + * and only `release`, called when a new race starts, clears it. + */ + signed: boolean + release: () => void prepare: (request: ChainActionRequest, asked: string) => void /** Shows a message in the card without building anything, for what can't be read. */ refuse: (message: string, asked: string) => void @@ -42,6 +55,11 @@ const EMPTY: ChainActionState = { phase: 'idle' } export function useChainAction(): ChainAction { const { slug, adapter } = useChain() const { address } = useWallet() + // Read at the moment of signing, not the render the click was made in. + const addressRef = useRef(address) + addressRef.current = address + const [signed, setSigned] = useState(false) + const release = useCallback(() => setSigned(false), []) const [state, setState] = useState(EMPTY) // Each prepare, refuse and reset starts a new generation. A response from an // older one is dropped, so a build that was superseded cannot become a @@ -85,7 +103,7 @@ export function useChainAction(): ChainAction { }) return } - setState({ phase: 'review', asked, prepared: body.prepared }) + setState({ phase: 'review', asked, prepared: body.prepared, account }) } void run().catch(() => { @@ -98,13 +116,33 @@ export function useChainAction(): ChainAction { const confirm = useCallback(() => { const prepared = state.prepared - if (prepared === undefined || address === undefined) return - const account = address + const builtFor = state.account + if (prepared === undefined || builtFor === undefined) return + const account = builtFor const mine = generation.current const current = (): boolean => generation.current === mine + // The payload was checked against this account only. A different wallet + // connected since the card was built stays on review with the reason. + const wrongWallet = (): boolean => { + const message = accountMismatch(addressRef.current, account) + if (message === undefined) return false + setState((s) => ({ ...s, phase: 'review', error: message })) + return true + } + if (wrongWallet()) return + async function run(prepared: PreparedAction): Promise { - setState((s) => ({ ...s, phase: 'signing' })) + // Checked again immediately before the wallet opens. + if (wrongWallet()) return + const family = prepared.signing.family + // A wallet that sends for itself may send and then report an error, so + // the latch is set before it is called, not after it answers. + if (latchBeforeSign(family)) setSigned(true) + setState((s) => { + const { error: _cleared, ...rest } = s + return { ...rest, phase: 'signing' } + }) const outcome = await adapter.signPrepared?.(prepared.signing) if (!current()) return @@ -112,47 +150,46 @@ export function useChainAction(): ChainAction { setState((s) => ({ ...s, phase: 'failed', error: 'This wallet cannot sign here yet.' })) return } - if (!outcome.ok) { - // Declining returns to review: the action is still on offer. - if (outcome.reason === 'rejected') { - setState((s) => ({ ...s, phase: 'review' })) - return - } - setState((s) => ({ - ...s, - phase: 'failed', - error: outcome.detail ?? 'The wallet could not sign this.', - })) + const step = afterSigning(outcome, family) + if (step.next === 'review') { + setState((s) => ({ ...s, phase: 'review' })) + return + } + if (step.next === 'unknown') { + setState((s) => ({ ...s, phase: 'pending', error: step.error })) + return + } + if (step.next === 'failed') { + setState((s) => ({ ...s, phase: 'failed', error: step.error })) return } - setState((s) => ({ ...s, phase: 'submitting', signed: true })) + setSigned(true) + setState((s) => ({ ...s, phase: 'submitting' })) const res = await fetch(`/api/chain/${slug}/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account, venueId: prepared.venueId, - signed: outcome.signed, + signed: step.signed, ...(prepared.context !== undefined ? { context: prepared.context } : {}), }), }) - const result = (await res.json()) as SubmitResult + const result = (await res.json()) as unknown if (!current()) return - if (!result.ok) { + const done = afterSubmitting(result, family) + if (!done.latched) setSigned(false) + if (done.phase === 'settled') { setState((s) => ({ ...s, - phase: result.pending === true ? 'pending' : 'failed', - error: result.error, + phase: 'settled', + hash: done.hash, + ...(done.explorerUrl !== undefined ? { explorerUrl: done.explorerUrl } : {}), })) return } - setState((s) => ({ - ...s, - phase: 'settled', - hash: result.hash, - explorerUrl: result.explorerUrl, - })) + setState((s) => ({ ...s, phase: done.phase, error: done.error })) } void run(prepared).catch(() => { @@ -164,7 +201,7 @@ export function useChainAction(): ChainAction { "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", })) }) - }, [state.prepared, address, adapter, slug]) + }, [state.prepared, state.account, adapter, slug]) - return { ...state, prepare, refuse, confirm, reset } + return { ...state, signed, release, prepare, refuse, confirm, reset } } diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts new file mode 100644 index 00000000..7673c657 --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + accountMismatch, + afterSigning, + afterSubmitting, + latchBeforeSign, +} from '../chains/signing-flow' +import { panelErrorMessage, reviewChainRoute } from '../agents/chain-race' +import type { ChainActionRequest } from '../chains/types' +import { CHECK_WALLET } from '../weave/cashout-view' + +const asked: ChainActionRequest = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } +const route = { + kind: 'chain', + request: asked, + venueId: 'ref', + venueName: 'Ref', + pay: { amount: '5', symbol: 'NEAR' }, +} + +function review(phase: Parameters[0]['phase'], signed: boolean) { + const prepare = vi.fn() + const outcome = reviewChainRoute({ + route, + asked, + text: 't', + phase, + signed, + prepare, + refuse: vi.fn(), + }) + return { outcome, prepare } +} + +describe('the latch before the wallet is called', () => { + it('is set for wallets that send for themselves', () => { + expect(latchBeforeSign('near')).toBe(true) + expect(latchBeforeSign('evm')).toBe(true) + }) + + it('is not set for wallets that return signed bytes', () => { + expect(latchBeforeSign('solana')).toBe(false) + expect(latchBeforeSign('sui')).toBe(false) + }) +}) + +describe('afterSigning', () => { + it.each(['wallet_error', 'rejected'] as const)( + 'a NEAR %s asks the user to check the wallet and never returns to review', + (reason) => { + const step = afterSigning({ ok: false, reason }, 'near') + expect(step.next).toBe('unknown') + expect((step as { error: string }).error).toContain(CHECK_WALLET) + expect((step as { error: string }).error).toContain('new message') + } + ) + + it('lets a signed-bytes wallet decline back to review', () => { + expect(afterSigning({ ok: false, reason: 'rejected' }, 'solana')).toEqual({ next: 'review' }) + expect(afterSigning({ ok: false, reason: 'rejected' }, 'sui')).toEqual({ next: 'review' }) + }) + + it('fails a signed-bytes wallet error plainly', () => { + expect(afterSigning({ ok: false, reason: 'wallet_error', detail: 'no' }, 'sui')).toEqual({ + next: 'failed', + error: 'no', + }) + }) + + it('submits what a wallet signed', () => { + const signed = { family: 'solana', signedTransaction: 'x' } as const + expect(afterSigning({ ok: true, signed }, 'solana')).toEqual({ next: 'submit', signed }) + }) +}) + +describe('afterSubmitting', () => { + it('keeps the latch on success and on an unknown outcome', () => { + expect(afterSubmitting({ ok: true, hash: 'h', explorerUrl: 'u' }, 'solana')).toMatchObject({ + phase: 'settled', + latched: true, + }) + expect( + afterSubmitting({ ok: false, pending: true, error: 'maybe sent it' }, 'sui') + ).toMatchObject({ phase: 'pending', latched: true }) + expect(afterSubmitting({ ok: false, error: 'odd' }, 'near')).toMatchObject({ + phase: 'pending', + latched: true, + }) + expect(afterSubmitting(undefined, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('releases the latch only for a clean refusal on a signed-bytes wallet', () => { + expect( + afterSubmitting({ ok: false, error: 'The quote moved too far.' }, 'solana') + ).toMatchObject({ phase: 'failed', latched: false }) + }) +}) + +describe('the two double-spend paths', () => { + it('Dismiss then Review after a pending result stays closed', () => { + // Dismiss resets the card to idle; the latch belongs to the race and survives. + const done = afterSubmitting({ ok: false, pending: true, error: 'maybe sent it' }, 'sui') + expect(done.latched).toBe(true) + const { outcome, prepare } = review('idle', done.latched) + expect(outcome).toBe('ignored') + expect(prepare).not.toHaveBeenCalled() + }) + + it('a NEAR wallet_error then Review stays closed, in every phase it can land in', () => { + const latched = latchBeforeSign('near') + expect(afterSigning({ ok: false, reason: 'wallet_error' }, 'near').next).toBe('unknown') + for (const phase of ['pending', 'failed', 'idle'] as const) { + const { outcome, prepare } = review(phase, latched) + expect(outcome).toBe('ignored') + expect(prepare).not.toHaveBeenCalled() + } + }) + + it('a decline on Solana before anything is sent can still be reviewed again', () => { + expect(afterSigning({ ok: false, reason: 'rejected' }, 'solana').next).toBe('review') + expect(review('review', latchBeforeSign('solana')).outcome).toBe('prepared') + }) +}) + +describe('accountMismatch on the review card', () => { + it('refuses a wallet other than the one the card was built for', () => { + expect(accountMismatch('b.near', 'a.near')).toContain('Nothing was sent') + expect(accountMismatch(undefined, 'a.near')).toBeDefined() + }) + + it('accepts the same account', () => { + expect(accountMismatch('a.near', 'a.near')).toBeUndefined() + }) +}) + +describe('panelErrorMessage', () => { + const said = 'No route could be priced right now. Nothing was signed or sent.' + it('keeps the sentence while nothing may be signed', () => { + expect(panelErrorMessage(said, false)).toBe(said) + }) + it('drops it once something may have been', () => { + expect(panelErrorMessage(said, true)).toBe('No route could be priced right now.') + }) +}) diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index da3ddd64..05026e4a 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -192,3 +192,13 @@ export function routeFigures( if (asked === undefined) return undefined return { headline: asked.kind === 'swap' ? 'Price unknown' : 'Rate not available' } } + +/** + * The panel's error text, without the claim that nothing was signed once + * something may have been. The server words its refusals with that sentence, + * which is true when they are sent and false after a direct-route signature. + */ +export function panelErrorMessage(message: string, signed: boolean): string { + if (!signed) return message + return message.replace(/s*Nothing was signed or sent.?/g, '').trim() +} diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts new file mode 100644 index 00000000..a26bd4c0 --- /dev/null +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -0,0 +1,74 @@ +import type { SignedOutcome, SignedPayload, SigningPayload } from './types' +import { + CHECK_WALLET, + accountMismatch, + afterSubmit, + afterWallet, + walletSends, +} from '../weave/cashout-view' + +type Family = SigningPayload['family'] + +export { accountMismatch } + +/** + * Whether handing the payload to the wallet may already move money. A wallet + * that broadcasts for itself can send and then report an error or a decline, + * so from the moment it is called the action must not be signed again. + */ +export function latchBeforeSign(family: Family): boolean { + return walletSends(family) +} + +export type AfterSigning = + | { next: 'submit'; signed: SignedPayload } + /** Signed-bytes wallet declined: nothing left the device, so the card stays on offer. */ + | { next: 'review' } + /** Signed-bytes wallet failed before anything was sent. */ + | { next: 'failed'; error: string } + /** Self-sending wallet gave no confirmation: it may have gone out. */ + | { next: 'unknown'; error: string } + +export function afterSigning(outcome: SignedOutcome, family: Family): AfterSigning { + const step = afterWallet(outcome, family) + if (step.next === 'check') { + return { + next: 'unknown', + error: `${CHECK_WALLET} If it did not go out, send a new message to start again.`, + } + } + return step +} + +export type AfterSubmitting = + | { phase: 'settled'; hash: string; explorerUrl: string | undefined; latched: true } + | { phase: 'pending'; error: string; latched: true } + | { phase: 'failed'; error: string; latched: false } + +/** + * The server's answer to submitted signed bytes. Only an explicit clean refusal + * releases the latch; every other answer leaves open that the money moved. + */ +export function afterSubmitting(result: unknown, family: Family): AfterSubmitting { + const step = afterSubmit(result, family) + if (step.next === 'tracking') { + return { + phase: 'settled', + hash: step.hash, + explorerUrl: step.explorerUrl === '' ? undefined : step.explorerUrl, + latched: true, + } + } + if (step.next === 'pending') { + const said = (result as { error?: unknown } | null)?.error + return { + phase: 'pending', + error: + typeof said === 'string' && said.includes(' ') + ? said + : "This may have gone through. Check your wallet's activity before trying again.", + latched: true, + } + } + return { phase: 'failed', error: step.error, latched: false } +} diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index 38e89f9b..3ab144cc 100644 --- a/apps/dapp/lib/weave/cashout-view.ts +++ b/apps/dapp/lib/weave/cashout-view.ts @@ -196,7 +196,7 @@ export function cleanIdentifier(text: string): string { type SigningFamily = SigningPayload['family'] /** These wallets broadcast for themselves, so an error back may follow a transfer that went out. */ -function walletSends(family: SigningFamily): boolean { +export function walletSends(family: SigningFamily): boolean { return family === 'near' || family === 'evm' } From 225af35150ab80bc4f1dcb57f5906019a9bfa359 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:10:20 +0100 Subject: [PATCH 10/24] fix(dapp): report an unknown submit outcome as pending so the latch holds The chain submit route, the Solana confirm timeout, Jupiter's unreadable or unknown reply and the NEAR confirm paths now say pending whenever the money may have moved. Only an answer that proves nothing moved stays a clean failure. The panel keeps a separate flag so it never says nothing was signed after a signature was asked for. --- .../app/api/chain/[chain]/submit/route.ts | 4 +- apps/dapp/components/intents/intent-chat.tsx | 2 +- apps/dapp/hooks/use-chain-action.ts | 15 ++++- .../lib/__tests__/chain-signing-flow.test.ts | 43 ++++++++++++++ .../chain-submit-route-unknown.test.ts | 56 +++++++++++++++++++ apps/dapp/lib/__tests__/solana-submit.test.ts | 22 +++++++- apps/dapp/lib/agents/chain-race.ts | 5 +- apps/dapp/lib/chains/near/stake.ts | 13 ++++- apps/dapp/lib/chains/near/swap.ts | 16 +++++- apps/dapp/lib/chains/solana/jupiter-swap.ts | 44 +++++++++++++-- apps/dapp/lib/chains/solana/submit.ts | 1 + apps/dapp/lib/weave/deposit/solana.ts | 8 +-- 12 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index 0d58363b..151b51e1 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -89,10 +89,12 @@ export async function POST( return NextResponse.json( { ok: false, + // Sent with no outcome known: the same contract as the cash-out route. + pending: true, error: "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", }, - { status: 502 } + { status: 202 } ) } } diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index f69d5bac..282dfa2c 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -2524,7 +2524,7 @@ export function IntentChat(): JSX.Element { chainReviewClosed(chainAction.phase, chainAction.signed === true) } directRoute={directFallback} - signed={chainAction.signed} + signed={chainAction.requested} {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index befa7e34..a5917149 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -41,6 +41,12 @@ export interface ChainAction extends ChainActionState { * and only `release`, called when a new race starts, clears it. */ signed: boolean + /** + * A signature was asked of the wallet in this race. Unlike `signed`, a clean + * failure does not clear it: something was signed, so "nothing was signed" + * must never be said again. Only a new race clears it. + */ + requested: boolean release: () => void prepare: (request: ChainActionRequest, asked: string) => void /** Shows a message in the card without building anything, for what can't be read. */ @@ -59,7 +65,11 @@ export function useChainAction(): ChainAction { const addressRef = useRef(address) addressRef.current = address const [signed, setSigned] = useState(false) - const release = useCallback(() => setSigned(false), []) + const [requested, setRequested] = useState(false) + const release = useCallback(() => { + setSigned(false) + setRequested(false) + }, []) const [state, setState] = useState(EMPTY) // Each prepare, refuse and reset starts a new generation. A response from an // older one is dropped, so a build that was superseded cannot become a @@ -139,6 +149,7 @@ export function useChainAction(): ChainAction { // A wallet that sends for itself may send and then report an error, so // the latch is set before it is called, not after it answers. if (latchBeforeSign(family)) setSigned(true) + setRequested(true) setState((s) => { const { error: _cleared, ...rest } = s return { ...rest, phase: 'signing' } @@ -203,5 +214,5 @@ export function useChainAction(): ChainAction { }) }, [state.prepared, state.account, adapter, slug]) - return { ...state, signed, release, prepare, refuse, confirm, reset } + return { ...state, signed, requested, release, prepare, refuse, confirm, reset } } diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts index 7673c657..560954b9 100644 --- a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -134,11 +134,54 @@ describe('accountMismatch on the review card', () => { }) }) +describe('unknown outcomes keep the latch and Review closed', () => { + // The body the chain submit route sends from its catch-all. + const route502 = { + ok: false, + pending: true, + error: + "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", + } + const notConfirmed = { + ok: false, + pending: true, + error: + 'This was sent, but not confirmed yet. Check https://solscan.io/tx/x before trying again.', + } + const unknownJupiterCode = { + ok: false, + pending: true, + error: + 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.', + } + + it.each([ + ['the route catch-all', route502], + ['a send not confirmed yet', notConfirmed], + ['an unknown Jupiter code', unknownJupiterCode], + ])('%s', (_name, body) => { + for (const family of ['solana', 'sui'] as const) { + const done = afterSubmitting(body, family) + expect(done).toMatchObject({ phase: 'pending', latched: true }) + for (const phase of ['pending', 'idle', 'failed'] as const) { + const { outcome, prepare } = review(phase, done.latched) + expect(outcome).toBe('ignored') + expect(prepare).not.toHaveBeenCalled() + } + } + }) +}) + describe('panelErrorMessage', () => { const said = 'No route could be priced right now. Nothing was signed or sent.' it('keeps the sentence while nothing may be signed', () => { expect(panelErrorMessage(said, false)).toBe(said) }) + it('drops it when it is not the last sentence', () => { + expect( + panelErrorMessage('Not priced. Nothing was signed or sent. Try again later.', true) + ).toBe('Not priced. Try again later.') + }) it('drops it once something may have been', () => { expect(panelErrorMessage(said, true)).toBe('No route could be priced right now.') }) diff --git a/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts b/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts new file mode 100644 index 00000000..d20e0c6b --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { afterSubmitting } from '../chains/signing-flow' + +const state = vi.hoisted(() => ({ submit: undefined as unknown })) + +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) +vi.mock('../chains/registry', () => ({ + chainModule: () => ({ + chain: 'solana', + submit: (input: unknown) => (state.submit as (i: unknown) => unknown)(input), + venues: [{ id: 'v', assertSafe: () => undefined }], + }), +})) + +const body = { + account: '11111111111111111111111111111111', + venueId: 'v', + signed: { family: 'solana', signedTransaction: 'abc' }, +} + +async function post(): Promise { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + return POST( + new Request('http://localhost/api/chain/solana/submit', { + method: 'POST', + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chain: 'solana' }) } + ) +} + +afterEach(() => vi.resetModules()) + +describe('the chain submit route when the outcome is unknown', () => { + it('answers pending (202) from its catch-all, which holds the latch', async () => { + state.submit = async () => { + throw new Error('rpc timed out') + } + const res = await post() + const json = await res.json() + expect(res.status).toBe(202) + expect(json).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('still answers 422 for a clean refusal, which releases the latch on a signed-bytes wallet', async () => { + state.submit = async () => ({ ok: false, error: 'The network rejected this transaction.' }) + const res = await post() + const json = await res.json() + expect(res.status).toBe(422) + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-submit.test.ts b/apps/dapp/lib/__tests__/solana-submit.test.ts index e337f63d..e470e309 100644 --- a/apps/dapp/lib/__tests__/solana-submit.test.ts +++ b/apps/dapp/lib/__tests__/solana-submit.test.ts @@ -183,7 +183,11 @@ describe('solana submit', () => { return status(null) }) const result = await submit({}, 2)(input(signed.transaction)) - expect(result).toMatchObject({ ok: false, error: expect.stringContaining('solscan.io') }) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringContaining('solscan.io'), + }) expect(result).toMatchObject({ error: expect.stringMatching(/before trying again/i) }) }) @@ -284,6 +288,22 @@ describe('jupiter execute', () => { expect(JSON.stringify(result)).not.toContain('upstream text') }) + it('marks only an unknown execute code as pending', async () => { + answering({ status: 'Failed', code: -9999 }) + await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ ok: false, pending: true }) + answering({ status: 'Failed', code: -1000 }) + const clean = await venue.submit?.(executeInput) + expect(clean).toMatchObject({ ok: false }) + expect(clean).not.toHaveProperty('pending') + }) + + it('treats a 200 with an unreadable body as unknown, not as nothing sent', async () => { + answering('not an execute reply', 200) + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).not.toContain('nothing was sent') + }) + it('reports a rejected request as nothing sent', async () => { answering({ error: 'bad' }, 400) await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index 05026e4a..1a0f6016 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -200,5 +200,8 @@ export function routeFigures( */ export function panelErrorMessage(message: string, signed: boolean): string { if (!signed) return message - return message.replace(/s*Nothing was signed or sent.?/g, '').trim() + return message + .replace(/\s*Nothing was signed or sent\.?/g, '') + .replace(/\s{2,}/g, ' ') + .trim() } diff --git a/apps/dapp/lib/chains/near/stake.ts b/apps/dapp/lib/chains/near/stake.ts index 854c5b75..87e67e25 100644 --- a/apps/dapp/lib/chains/near/stake.ts +++ b/apps/dapp/lib/chains/near/stake.ts @@ -253,11 +253,18 @@ export async function confirmStake(input: SubmitInput): Promise { const contract = Object.prototype.hasOwnProperty.call(STAKE_CONTRACTS, venueId) ? STAKE_CONTRACTS[venueId] : undefined - if (contract === undefined) return { ok: false, error: 'Nothing to confirm for that venue.' } + // The wallet has already sent, so nothing here proves that nothing moved. + if (contract === undefined) { + return { ok: false, pending: true, error: 'Nothing to confirm for that venue.' } + } const hash = signed.hashes[signed.hashes.length - 1] if (hash === undefined || signed.hashes.some((h) => !NEAR_TX_HASH.test(h))) { - return { ok: false, error: 'The wallet did not return a transaction hash to follow.' } + return { + ok: false, + pending: true, + error: 'The wallet did not return a transaction hash to follow.', + } } const giveUpAt = Date.now() + CONFIRM_WAIT_MS @@ -266,6 +273,7 @@ export async function confirmStake(input: SubmitInput): Promise { if (outcome.state === 'unknown') { return { ok: false, + pending: true, error: `Your transaction was sent (${each}) but hasn't been confirmed yet. Don't send it again. Check ${explorerUrl(each)} in a minute.`, } } @@ -279,6 +287,7 @@ export async function confirmStake(input: SubmitInput): Promise { if (outcome.signer !== account || outcome.receiver !== contract || !stakes) { return { ok: false, + pending: true, error: `Transaction ${each} is not this account's stake with this venue. Check your wallet's activity.`, } } diff --git a/apps/dapp/lib/chains/near/swap.ts b/apps/dapp/lib/chains/near/swap.ts index 0ab120ef..e3dfb8a0 100644 --- a/apps/dapp/lib/chains/near/swap.ts +++ b/apps/dapp/lib/chains/near/swap.ts @@ -412,11 +412,16 @@ export async function submitOneClick(input: SubmitInput): Promise const hash = signed.hashes[signed.hashes.length - 1] const depositAddress = context?.['depositAddress'] if (hash === undefined || !NEAR_TX_HASH.test(hash)) { - return { ok: false, error: 'The wallet did not return a transaction hash to follow.' } + return { + ok: false, + pending: true, + error: 'The wallet did not return a transaction hash to follow.', + } } if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) { return { ok: false, + pending: true, error: `This swap's deposit address was lost, so it can't be followed from here. Your transaction is ${hash}. Check your wallet before trying again.`, } } @@ -436,6 +441,7 @@ export async function submitOneClick(input: SubmitInput): Promise if (outcome.signer !== account || !paysDeposit) { return { ok: false, + pending: true, error: `Transaction ${hash} is not this account's deposit for this swap, so it was not reported to NEAR Intents. Check your wallet's activity.`, } } @@ -448,7 +454,11 @@ export async function submitOneClick(input: SubmitInput): Promise lastStatus = body?.status ?? lastStatus const recipient = body?.quoteResponse?.quoteRequest?.recipient if (recipient !== undefined && recipient !== account) { - return { ok: false, error: 'That deposit address belongs to a different swap.' } + return { + ok: false, + pending: true, + error: 'That deposit address belongs to a different swap.', + } } switch (body?.status) { case 'SUCCESS': @@ -461,11 +471,13 @@ export async function submitOneClick(input: SubmitInput): Promise case 'FAILED': return { ok: false, + pending: true, error: `The swap failed after your deposit (${hash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`, } case 'INCOMPLETE_DEPOSIT': return { ok: false, + pending: true, error: `NEAR Intents received less than the quote needed, so it won't swap. What arrived is due back at ${account}. ${EXPLORER_NOTE} ${depositAddress}.`, } default: diff --git a/apps/dapp/lib/chains/solana/jupiter-swap.ts b/apps/dapp/lib/chains/solana/jupiter-swap.ts index a7dc9bd4..d6aed923 100644 --- a/apps/dapp/lib/chains/solana/jupiter-swap.ts +++ b/apps/dapp/lib/chains/solana/jupiter-swap.ts @@ -76,17 +76,37 @@ function explainOrderError(code: number | undefined, tokenIn: ChainToken): strin return 'Jupiter could not build this swap. Nothing was signed or sent. Check that you hold the token and a little SOL for fees, then try again.' } -function explainExecuteFailure(code: number | undefined): string { +/** + * A code that proves the swap did not land is a clean failure. Any other code + * leaves open that it did, so it is reported as unknown. + */ +function explainExecuteFailure(code: number | undefined): { error: string; clean: boolean } { if (code === -1 || code === -1004 || code === -1005 || code === -2003) { - return 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.' + return { + error: + 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.', + clean: true, + } } if (code === -1000 || code === -2000) { - return 'This swap did not land on the network, so nothing moved. Get a fresh quote and try again.' + return { + error: + 'This swap did not land on the network, so nothing moved. Get a fresh quote and try again.', + clean: true, + } } if (code === -2 || code === -3 || code === -1002 || code === -1003 || code === -2002) { - return 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.' + return { + error: + 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.', + clean: true, + } + } + return { + error: + 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.', + clean: false, } - return 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.' } function shortAmount(amount: string): string { @@ -274,6 +294,15 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue throw new Error(`Jupiter /execute answered ${response.status}`) } const parsed = Executed.safeParse(await response.json().catch(() => undefined)) + // A 200 whose body cannot be read says nothing about whether the swap landed. + if (response.ok && !parsed.success) { + return { + ok: false, + pending: true, + error: + 'Jupiter answered, but the reply could not be read, so this swap may have gone through. Check your wallet’s activity before trying again.', + } + } if (!response.ok || !parsed.success) { return { ok: false, @@ -285,7 +314,10 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue if (status === 'Success' && signature !== undefined) { return { ok: true, hash: signature, explorerUrl: explorerUrl(signature) } } - return { ok: false, error: explainExecuteFailure(code) } + const failure = explainExecuteFailure(code) + return failure.clean + ? { ok: false, error: failure.error } + : { ok: false, pending: true, error: failure.error } } return { diff --git a/apps/dapp/lib/chains/solana/submit.ts b/apps/dapp/lib/chains/solana/submit.ts index aa2fa05c..19761e06 100644 --- a/apps/dapp/lib/chains/solana/submit.ts +++ b/apps/dapp/lib/chains/solana/submit.ts @@ -130,6 +130,7 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { if (valid.success && !valid.data.value) return { ok: false, error: EXPIRED } return { ok: false, + pending: true, error: `This was sent, but not confirmed yet. Check ${explorerUrl(hash)} before trying again, in case it goes through.`, } } diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index f7a83e90..b22ad284 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -64,12 +64,6 @@ const COMPUTE_UNIT_LIMIT = 100_000 /** About 0.00001 SOL at the limit above, enough to land without a live fee estimate. */ const COMPUTE_UNIT_PRICE = BigInt(100_000) -/** - * `createSolanaSubmit` has no flag for a send it can't confirm yet, only this wording. - * The tests run the real function, so a change to the wording fails them. - */ -const UNCONFIRMED_SEND = 'not confirmed yet' - const NOT_ONE_TRANSFER = 'This transaction asks the Token Program for something other than one USDC transfer.' @@ -417,7 +411,7 @@ async function submit( // A connection that broke during the send says nothing about whether it landed. return pending(hash) } - return !result.ok && result.error.includes(UNCONFIRMED_SEND) ? pending(hash) : result + return !result.ok && result.pending === true ? pending(hash) : result } export const solanaDeposit: DepositChain = { From 26019eab8277caffcf552701d00553cbce66e0b4 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:24:46 +0100 Subject: [PATCH 11/24] fix(dapp): only call a solana submit clean when the answer proves nothing moved A clean answer re-opens signing, so a false one can spend a user's money twice. - Expired blockhash takes a final status read after the validity answer, and any unanswered poll or earlier sighting now means pending. - A failure in a block only counts at confirmed or finalized. - Send errors are clean only for -32002 and the preflight error names, and a non-2xx RPC answer or AlreadyProcessed is unknown. - Jupiter -1000, -2000 and non-2xx without a known clean code are pending, and pending wording says not to send again and names Jupiter's signature. - The route separates assertSafe failures, which sent nothing, from later throws. --- .../app/api/chain/[chain]/submit/route.ts | 33 +- .../lib/__tests__/chain-signing-flow.test.ts | 2 +- .../chain-submit-route-unknown.test.ts | 65 ++- apps/dapp/lib/__tests__/solana-submit.test.ts | 376 ++++++++++++++++-- .../__tests__/weave-deposit-solana.test.ts | 34 ++ apps/dapp/lib/chains/solana/jupiter-swap.ts | 72 ++-- apps/dapp/lib/chains/solana/submit.ts | 91 ++++- 7 files changed, 564 insertions(+), 109 deletions(-) diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index 151b51e1..f8ffce41 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -58,16 +58,31 @@ export async function POST( return NextResponse.json({ error: 'wrong_chain' }, { status: 400 }) } - try { - // A NEAR wallet has already sent its transactions, so there are no bytes left to check. - if (signed.family !== 'near') { - const payload: SigningPayload = - signed.family === 'solana' - ? { family: 'solana', transaction: signed.signedTransaction } - : { family: 'sui', transaction: signed.transaction } + // A NEAR wallet has already sent its transactions, so there are no bytes left to check. + if (signed.family !== 'near') { + const payload: SigningPayload = + signed.family === 'solana' + ? { family: 'solana', transaction: signed.signedTransaction } + : { family: 'sui', transaction: signed.transaction } + try { venue.assertSafe(payload, account) + } catch (e) { + // This route is what sends the bytes, so any failure here means they never left. + if (e instanceof ChainActionRefused) { + return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) + } + reportError('chain/submit', e, { chain: chainApi.chain }) + return NextResponse.json( + { + ok: false, + error: 'This transaction could not be checked, so nothing was sent. Build it again.', + }, + { status: 422 } + ) } + } + try { const input = { account, venueId, signed, ...(context !== undefined ? { context } : {}) } const result = await (venue.submit ?? chainApi.submit)(input) await logExecution({ @@ -82,13 +97,11 @@ export async function POST( status: result.ok ? 200 : result.pending === true ? 202 : 422, }) } catch (e) { - if (e instanceof ChainActionRefused) { - return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) - } reportError('chain/submit', e, { chain: chainApi.chain }) return NextResponse.json( { ok: false, + // Sending may have started, so no throw from here on can prove nothing moved. // Sent with no outcome known: the same contract as the cash-out route. pending: true, error: diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts index 560954b9..8395e746 100644 --- a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -152,7 +152,7 @@ describe('unknown outcomes keep the latch and Review closed', () => { ok: false, pending: true, error: - 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.', + 'Jupiter hasn’t confirmed this swap, and it may still have gone through, so don’t send it again. Check your wallet’s activity first, and get a fresh quote only if nothing changed.', } it.each([ diff --git a/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts b/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts index d20e0c6b..b399a1d2 100644 --- a/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts +++ b/apps/dapp/lib/__tests__/chain-submit-route-unknown.test.ts @@ -1,8 +1,12 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterSubmitting } from '../chains/signing-flow' -const state = vi.hoisted(() => ({ submit: undefined as unknown })) +const state = vi.hoisted(() => ({ + submit: undefined as unknown, + assertSafe: undefined as unknown, + submitted: 0, +})) vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) @@ -10,8 +14,11 @@ vi.mock('../server/report', () => ({ reportError: () => undefined })) vi.mock('../chains/registry', () => ({ chainModule: () => ({ chain: 'solana', - submit: (input: unknown) => (state.submit as (i: unknown) => unknown)(input), - venues: [{ id: 'v', assertSafe: () => undefined }], + submit: (input: unknown) => { + state.submitted += 1 + return (state.submit as (i: unknown) => unknown)(input) + }, + venues: [{ id: 'v', assertSafe: () => (state.assertSafe as () => void)() }], }), })) @@ -32,6 +39,11 @@ async function post(): Promise { ) } +beforeEach(() => { + state.assertSafe = () => undefined + state.submitted = 0 +}) + afterEach(() => vi.resetModules()) describe('the chain submit route when the outcome is unknown', () => { @@ -46,6 +58,19 @@ describe('the chain submit route when the outcome is unknown', () => { expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'pending', latched: true }) }) + it('answers pending even if a refusal is thrown once sending has started', async () => { + // Loaded here because the route gets a fresh copy of this module after each reset. + const { ChainActionRefused } = await import('../chains/types') + state.submit = async () => { + throw new ChainActionRefused('Refused after the send.') + } + const res = await post() + const json = await res.json() + expect(res.status).toBe(202) + expect(json).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + it('still answers 422 for a clean refusal, which releases the latch on a signed-bytes wallet', async () => { state.submit = async () => ({ ok: false, error: 'The network rejected this transaction.' }) const res = await post() @@ -54,3 +79,35 @@ describe('the chain submit route when the outcome is unknown', () => { expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'failed', latched: false }) }) }) + +describe('the chain submit route before anything is sent', () => { + it('answers 422 with the refusal when the signed bytes fail the safety check', async () => { + const { ChainActionRefused } = await import('../chains/types') + state.assertSafe = () => { + throw new ChainActionRefused('This transaction calls a program it should not need.') + } + state.submit = async () => ({ ok: true, hash: 'x', explorerUrl: 'y' }) + const res = await post() + expect(res.status).toBe(422) + await expect(res.json()).resolves.toEqual({ + ok: false, + error: 'This transaction calls a program it should not need.', + }) + expect(state.submitted).toBe(0) + }) + + it('says nothing was sent when the safety check itself breaks', async () => { + state.assertSafe = () => { + throw new TypeError('cannot read the transaction') + } + state.submit = async () => ({ ok: true, hash: 'x', explorerUrl: 'y' }) + const res = await post() + const json = (await res.json()) as { error: string; pending?: boolean } + expect(res.status).toBe(422) + expect(json.pending).toBeUndefined() + expect(json.error).toMatch(/nothing was sent/i) + expect(JSON.stringify(json)).not.toContain('cannot read the transaction') + expect(afterSubmitting(json, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + expect(state.submitted).toBe(0) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-submit.test.ts b/apps/dapp/lib/__tests__/solana-submit.test.ts index e470e309..9c666d34 100644 --- a/apps/dapp/lib/__tests__/solana-submit.test.ts +++ b/apps/dapp/lib/__tests__/solana-submit.test.ts @@ -1,6 +1,7 @@ import { getBase58Decoder } from '@solana/kit' import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterSubmitting } from '../chains/signing-flow' import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap' import { createSolanaSubmit } from '../chains/solana/submit' import { @@ -173,7 +174,7 @@ describe('solana submit', () => { }) const result = await submit({}, 3)(input(signed.transaction)) expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/expired/i) }) - expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(3) + expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(4) }) it('does not claim failure while the transaction could still land', async () => { @@ -213,6 +214,266 @@ describe('solana submit', () => { }) await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ ok: true }) }) + + describe('blockhash invalid after the send was accepted', () => { + const EXPIRED = + 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.' + const THROWS = Symbol('the read never got an answer') + const RPC_ERROR = Symbol('the read was answered with a JSON-RPC error') + const EMPTY = Symbol('the read was answered with an empty list') + const processed = { confirmationStatus: 'processed', err: null } + const confirmed = { confirmationStatus: 'confirmed', err: null } + + /** Three polls and then the final read, each answered in turn. */ + function reads(answers: unknown[], blockhashValid = false) { + let read = 0 + return stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + if (method === 'isBlockhashValid') + return ok({ context: { slot: 1 }, value: blockhashValid }) + const answer = answers[read] + read += 1 + if (answer === THROWS) throw new Error('socket hang up') + if (answer === RPC_ERROR) return { error: { code: -32005, message: 'Node is behind' } } + if (answer === EMPTY) return ok({ context: { slot: 1 }, value: [] }) + return status(answer) + }) + } + + it('stays pending when a poll failed while the transaction landed', async () => { + reads([THROWS, null, null, null]) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).not.toContain('nothing moved') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it.each([ + ['every poll and the final read throw', [THROWS, THROWS, THROWS, THROWS]], + ['only the last poll throws', [null, null, THROWS, null]], + ['a poll gets a JSON-RPC error', [null, RPC_ERROR, null, null]], + ['a poll gets an empty list', [EMPTY, null, null, null]], + ['only the final read throws', [null, null, null, THROWS]], + ['only the final read gets a JSON-RPC error', [null, null, null, RPC_ERROR]], + ['a poll saw it processed and later reads lost it', [processed, null, null, null]], + ['the final read sees it processed', [null, null, null, processed]], + ])('stays pending when %s', async (_name, answers) => { + reads(answers) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + + it('counts a status read answered with a server error as unanswered', async () => { + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + const { method } = JSON.parse(String(init.body)) as { method: string } + if (method === 'sendTransaction') { + return new Response(JSON.stringify({ result: signed.signature })) + } + if (method === 'isBlockhashValid') { + return new Response(JSON.stringify({ result: { value: false } })) + } + return new Response(JSON.stringify({ result: { value: [null] } }), { status: 503 }) + }) + await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ + ok: false, + pending: true, + }) + }) + + it('is final only when every poll and the final read answered and none saw it', async () => { + reads([null, null, null, null]) + await expect(submit()(input(signed.transaction))).resolves.toEqual({ + ok: false, + error: EXPIRED, + }) + }) + + it('makes the final read after the blockhash answer, searching history', async () => { + const { calls } = reads([null, null, null, null]) + await submit()(input(signed.transaction)) + expect(calls.at(-2)?.method).toBe('isBlockhashValid') + expect(calls.at(-1)?.method).toBe('getSignatureStatuses') + expect(calls.at(-1)?.params[0]).toEqual([signed.signature]) + expect(calls.at(-1)?.params[1]).toEqual({ searchTransactionHistory: true }) + }) + + it('reports success when the final read finds it confirmed', async () => { + reads([THROWS, null, null, confirmed]) + await expect(submit()(input(signed.transaction))).resolves.toEqual({ + ok: true, + hash: signed.signature, + explorerUrl: `https://solscan.io/tx/${signed.signature}`, + }) + }) + + it('does not make a final read while the blockhash is still valid', async () => { + const { calls } = reads([null, null, null], true) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(3) + }) + }) + + describe('a transaction that failed in a block', () => { + const failed = { InstructionError: [0, 'x'] } + + it('does not believe a processed failure, which can sit on a dropped fork', async () => { + stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + if (method === 'isBlockhashValid') return ok({ context: { slot: 1 }, value: true }) + return status({ confirmationStatus: 'processed', err: failed }) + }) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).not.toContain('did not move') + }) + + it('stays pending when a processed failure is all there is at expiry', async () => { + stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + if (method === 'isBlockhashValid') return ok({ context: { slot: 1 }, value: false }) + return status({ confirmationStatus: 'processed', err: failed }) + }) + await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ + ok: false, + pending: true, + }) + }) + + it('reports the success that follows a processed failure on a dropped fork', async () => { + let polls = 0 + stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + polls += 1 + return polls === 1 + ? status({ confirmationStatus: 'processed', err: failed }) + : status({ confirmationStatus: 'confirmed', err: null }) + }) + await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ ok: true }) + }) + + it.each(['confirmed', 'finalized'])('believes a failure at %s', async (confirmationStatus) => { + stubRpc((method) => + method === 'sendTransaction' + ? ok(signed.signature) + : status({ confirmationStatus, err: failed }) + ) + const result = await submit()(input(signed.transaction)) + expect(result).toEqual({ + ok: false, + error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: https://solscan.io/tx/${signed.signature}`, + }) + }) + }) + + describe('what the node answered to the send', () => { + it.each([ + [ + 'an instruction failing in simulation', + { + code: -32002, + message: 'Transaction simulation failed', + data: { err: { InstructionError: [1, { Custom: 6001 }] }, logs: [] }, + }, + 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.', + ], + [ + 'BlockhashNotFound', + { + code: -32002, + message: 'Transaction simulation failed: Blockhash not found', + data: { err: 'BlockhashNotFound', logs: [] }, + }, + 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.', + ], + [ + 'a blockhash that is only named in the -32002 message', + { code: -32002, message: 'Transaction simulation failed: Blockhash not found' }, + 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.', + ], + [ + 'AccountNotFound', + { code: -32002, message: 'simulation failed', data: { err: 'AccountNotFound', logs: [] } }, + 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.', + ], + [ + 'InsufficientFundsForFee', + { + code: -32002, + message: 'simulation failed', + data: { err: 'InsufficientFundsForFee', logs: [] }, + }, + 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.', + ], + [ + 'a balance error in the simulation logs', + { + code: -32002, + message: 'Transaction simulation failed', + data: { + err: { InstructionError: [1, { Custom: 1 }] }, + logs: ['Program log: Error: insufficient funds'], + }, + }, + 'This wallet’s balance is too low for this transaction, so nothing was sent. Check your balance, including a little SOL for fees, and try again.', + ], + ])('keeps %s clean, as written', async (_name, error, wording) => { + const { calls } = stubRpc(() => ({ error })) + const result = await submit()(input(signed.transaction)) + expect(result).toEqual({ ok: false, error: wording }) + expect(calls).toHaveLength(1) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + }) + + it.each([ + ['an internal error', { code: -32603, message: 'Internal error' }], + ['a node that is behind', { code: -32005, message: 'Node is behind by 42 slots' }], + ['a gateway error with no code', { message: 'Bad gateway' }], + ['a gateway that mentions simulation', { code: -32603, message: 'Simulation timed out' }], + ['a gateway that mentions the blockhash', { code: -32000, message: 'Blockhash not found' }], + [ + 'balance words outside a preflight failure', + { code: -32603, message: 'x', data: { logs: ['insufficient funds'] } }, + ], + [ + 'a transaction that already landed', + { + code: -32002, + message: 'Transaction simulation failed: This transaction has already been processed', + data: { err: 'AlreadyProcessed', logs: [] }, + }, + ], + [ + 'an already processed message with no error name', + { + code: -32002, + message: 'Transaction simulation failed: This transaction has already been processed', + }, + ], + ])('treats %s as unknown', async (_name, error) => { + stubRpc(() => ({ error })) + const result = await submit()(input(signed.transaction)) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringContaining(`https://solscan.io/tx/${signed.signature}`), + }) + expect(JSON.stringify(result)).not.toMatch(/nothing was sent|nothing moved/) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('does not read a refusal out of a non-2xx answer', async () => { + vi.stubGlobal( + 'fetch', + async () => + new Response( + JSON.stringify({ error: { code: -32002, message: 'Transaction simulation failed' } }), + { status: 502 } + ) + ) + await expect(submit()(input(signed.transaction))).rejects.toThrow() + }) + }) }) describe('jupiter execute', () => { @@ -274,42 +535,103 @@ describe('jupiter execute', () => { expect(urls).toHaveLength(0) }) + const EXPIRED_SWAP = + 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.' + const UNREADABLE_SWAP = + 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.' + it.each([ - [-1, /expired|fresh quote/i], - [-1005, /expired/i], - [-1004, /expired/i], - [-1000, /did not land/i], - [-1003, /sign/i], - [-9999, /did not go through/i], - ])('explains execute code %i', async (code, message) => { + [-1, EXPIRED_SWAP], + [-1004, EXPIRED_SWAP], + [-1005, EXPIRED_SWAP], + [-2003, EXPIRED_SWAP], + [-2, UNREADABLE_SWAP], + [-3, UNREADABLE_SWAP], + [-1002, UNREADABLE_SWAP], + [-1003, UNREADABLE_SWAP], + [-2002, UNREADABLE_SWAP], + ])('keeps execute code %i clean, as written', async (code, wording) => { answering({ status: 'Failed', code, error: 'upstream text' }) const result = await venue.submit?.(executeInput) - expect(result).toMatchObject({ ok: false, error: expect.stringMatching(message) }) - expect(JSON.stringify(result)).not.toContain('upstream text') - }) - - it('marks only an unknown execute code as pending', async () => { - answering({ status: 'Failed', code: -9999 }) - await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ ok: false, pending: true }) - answering({ status: 'Failed', code: -1000 }) - const clean = await venue.submit?.(executeInput) - expect(clean).toMatchObject({ ok: false }) - expect(clean).not.toHaveProperty('pending') + expect(result).toEqual({ ok: false, error: wording }) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'failed', latched: false }) }) - it('treats a 200 with an unreadable body as unknown, not as nothing sent', async () => { - answering('not an execute reply', 200) + it.each([ + ['-1000, which only says it failed to land', { status: 'Failed', code: -1000 }], + ['-2000, which only says it failed to land', { status: 'Failed', code: -2000 }], + ['an unknown code', { status: 'Failed', code: -9999, error: 'upstream text' }], + ['a failure with no code', { status: 'Failed' }], + ['Success with no signature', { status: 'Success', code: 0 }], + ['Success with a clean code and no signature', { status: 'Success', code: -1 }], + ['a status it has never used', { status: 'Processing', code: -1 }], + ['a clean code with no status on a 200', { code: -1 }], + ['an unreadable body on a 200', 'not an execute reply'], + ])('treats %s as unknown', async (_name, body) => { + answering(body) const result = await venue.submit?.(executeInput) - expect(result).toMatchObject({ ok: false, pending: true }) - expect(JSON.stringify(result)).not.toContain('nothing was sent') + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringMatching(/don.t send it again/i), + }) + expect(result).toMatchObject({ error: expect.stringMatching(/wallet.s activity/i) }) + expect(JSON.stringify(result)).not.toMatch( + /did not go through|did not land|nothing moved|nothing was sent|upstream text/ + ) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) }) - it('reports a rejected request as nothing sent', async () => { - answering({ error: 'bad' }, 400) - await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ + it.each([ + ['-1000', { status: 'Failed', code: -1000, signature: 'jupiter-signature' }, 200], + ['an unknown code', { status: 'Failed', code: -9999, signature: 'jupiter-signature' }, 200], + ['a non-2xx answer', { status: 'Success', signature: 'jupiter-signature' }, 400], + ])('points at Jupiter’s signature for %s', async (_name, body, statusCode) => { + answering(body, statusCode) + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ ok: false, - error: expect.stringMatching(/fresh quote/i), + pending: true, + error: expect.stringContaining('https://solscan.io/tx/jupiter-signature'), }) + expect(result).toMatchObject({ error: expect.stringMatching(/don.t send it again/i) }) + }) + + it.each([400, 401, 403, 404, 408, 409, 422])( + 'treats a %i with no known code as unknown', + async (statusCode) => { + answering({ error: 'bad' }, statusCode) + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ + ok: false, + pending: true, + error: expect.stringMatching(/don.t send it again/i), + }) + expect(JSON.stringify(result)).not.toContain('nothing was sent') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + } + ) + + it('treats a non-2xx with an unreadable body as unknown', async () => { + vi.stubGlobal('fetch', async () => new Response('Bad request', { status: 400 })) + await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it.each([ + ['-1000', { status: 'Failed', code: -1000 }], + ['an unknown code', { code: -9999 }], + ['a clean code beside a Success status', { status: 'Success', code: -1 }], + ])('treats a 400 carrying %s as unknown', async (_name, body) => { + answering(body, 400) + await expect(venue.submit?.(executeInput)).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it.each([ + ['with a Failed status', { status: 'Failed', code: -1005 }, EXPIRED_SWAP], + ['with no status', { code: -2 }, UNREADABLE_SWAP], + ])('believes a known clean code on a 400 %s', async (_name, body, wording) => { + answering(body, 400) + await expect(venue.submit?.(executeInput)).resolves.toEqual({ ok: false, error: wording }) }) it('leaves an unknown outcome to the route, which tells the user to check their wallet', async () => { diff --git a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts index bb9274e5..cbcd80f3 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts @@ -670,6 +670,40 @@ describe('submit', () => { expect(result).toMatchObject({ ok: false, pending: true }) }) + it('reports a node error that proves nothing as pending', async () => { + stubRpc({ send: () => ({ error: { code: -32603, message: 'Internal error' } }) }) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: good }, + EXPECTATION + ) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toMatch(/send it again/i) + }) + + it('reports a failed status read before expiry as pending, not as expired', async () => { + stubRpc({ + statuses: { error: { code: -32005, message: 'Node is behind' } }, + isValid: false, + }) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: good }, + EXPECTATION + ) + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toMatch(/send it again/i) + }) + + it('reports a failure seen only at processed as pending', async () => { + stubRpc({ + statuses: { result: { value: [{ confirmationStatus: 'processed', err: { x: 1 } }] } }, + }) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: good }, + EXPECTATION + ) + expect(result).toMatchObject({ ok: false, pending: true }) + }) + it('reports an expired transaction as a plain failure', async () => { stubRpc({ statuses: { result: { value: [null] } }, isValid: false }) const result = await solanaDeposit.submit( diff --git a/apps/dapp/lib/chains/solana/jupiter-swap.ts b/apps/dapp/lib/chains/solana/jupiter-swap.ts index d6aed923..87f2270d 100644 --- a/apps/dapp/lib/chains/solana/jupiter-swap.ts +++ b/apps/dapp/lib/chains/solana/jupiter-swap.ts @@ -55,8 +55,8 @@ const Order = z.object({ }) const Executed = z.object({ - status: z.string(), - signature: z.string().optional(), + status: z.string().optional(), + signature: z.string().min(1).optional(), code: z.number().optional(), }) @@ -77,35 +77,27 @@ function explainOrderError(code: number | undefined, tokenIn: ChainToken): strin } /** - * A code that proves the swap did not land is a clean failure. Any other code - * leaves open that it did, so it is reported as unknown. + * The sentence for a code that proves the swap did not land, or nothing. -1000 and -2000 + * only say "Failed to land", and Jupiter's docs treat the blockhash's last valid height as + * the real expiry, so those leave open that it still lands and don't get a sentence here. */ -function explainExecuteFailure(code: number | undefined): { error: string; clean: boolean } { +function explainCleanFailure(code: number | undefined): string | undefined { if (code === -1 || code === -1004 || code === -1005 || code === -2003) { - return { - error: - 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.', - clean: true, - } - } - if (code === -1000 || code === -2000) { - return { - error: - 'This swap did not land on the network, so nothing moved. Get a fresh quote and try again.', - clean: true, - } + return 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.' } if (code === -2 || code === -3 || code === -1002 || code === -1003 || code === -2002) { - return { - error: - 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.', - clean: true, - } + return 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.' } + return undefined +} + +/** The swap may have landed, so this must not read as a failure that invites a retry. */ +function unconfirmedSwap(signature: string | undefined): SubmitResult { + const where = signature !== undefined ? explorerUrl(signature) : 'your wallet’s activity' return { - error: - 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.', - clean: false, + ok: false, + pending: true, + error: `Jupiter hasn’t confirmed this swap, and it may still have gone through, so don’t send it again. Check ${where} first, and get a fresh quote only if nothing changed.`, } } @@ -294,30 +286,18 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue throw new Error(`Jupiter /execute answered ${response.status}`) } const parsed = Executed.safeParse(await response.json().catch(() => undefined)) - // A 200 whose body cannot be read says nothing about whether the swap landed. - if (response.ok && !parsed.success) { - return { - ok: false, - pending: true, - error: - 'Jupiter answered, but the reply could not be read, so this swap may have gone through. Check your wallet’s activity before trying again.', - } - } - if (!response.ok || !parsed.success) { - return { - ok: false, - error: - 'Jupiter would not take this swap, so nothing was sent. Get a fresh quote and try again.', - } - } + // A body that cannot be read says nothing about whether the swap landed. + if (!parsed.success) return unconfirmedSwap(undefined) const { status, signature, code } = parsed.data - if (status === 'Success' && signature !== undefined) { + if (response.ok && status === 'Success' && signature !== undefined) { return { ok: true, hash: signature, explorerUrl: explorerUrl(signature) } } - const failure = explainExecuteFailure(code) - return failure.clean - ? { ok: false, error: failure.error } - : { ok: false, pending: true, error: failure.error } + // /execute can answer long after broadcast and documents no non-2xx statuses, so the + // status alone proves nothing. Only a failure carrying a known code does, and an error + // body may not repeat the status field. + const failed = status === 'Failed' || (!response.ok && status === undefined) + const refusal = failed ? explainCleanFailure(code) : undefined + return refusal !== undefined ? { ok: false, error: refusal } : unconfirmedSwap(signature) } return { diff --git a/apps/dapp/lib/chains/solana/submit.ts b/apps/dapp/lib/chains/solana/submit.ts index 19761e06..11c29e3f 100644 --- a/apps/dapp/lib/chains/solana/submit.ts +++ b/apps/dapp/lib/chains/solana/submit.ts @@ -36,27 +36,55 @@ const Statuses = z.object({ ), }) +type Status = NonNullable['value'][number]> + const BlockhashValid = z.object({ value: z.boolean() }) const EXPIRED = 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.' -/** What the network said, in a sentence. Raw logs stay out of what the user reads. */ -function explainSendError(error: z.infer): string { +/** + * What the network said, in a sentence, when its answer proves nothing was sent. A node + * forwards a transaction only after preflight passes, so -32002 and the error names only + * preflight reports are that proof. Other errors, and any wording outside -32002, could come + * from a gateway that already passed the transaction on, so they return nothing. + * Raw logs stay out of what the user reads. + */ +function explainSendError(error: z.infer): string | undefined { const err = error.data?.err const logs = (error.data?.logs ?? []).join(' ').toLowerCase() const message = (error.message ?? '').toLowerCase() - if (err === 'BlockhashNotFound' || message.includes('blockhash not found')) return EXPIRED + const preflight = error.code === -32002 + // Preflight fails this way because the same transaction is already in a block. + if (err === 'AlreadyProcessed' || message.includes('already been processed')) return undefined + if (err === 'BlockhashNotFound' || (preflight && message.includes('blockhash not found'))) { + return EXPIRED + } if (err === 'AccountNotFound' || err === 'InsufficientFundsForFee') { return 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.' } + if (!preflight) return undefined if (logs.includes('insufficient funds') || logs.includes('insufficient lamports')) { return 'This wallet’s balance is too low for this transaction, so nothing was sent. Check your balance, including a little SOL for fees, and try again.' } - if (error.code === -32002 || message.includes('simulation')) { - return 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.' + return 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.' +} + +/** + * The outcome a status settles, or nothing. A processed block can sit on a fork that + * gets dropped, so neither its success nor its failure is final. + */ +function settledBy(status: Status, hash: string): SubmitResult | undefined { + if (status.confirmationStatus !== 'confirmed' && status.confirmationStatus !== 'finalized') { + return undefined } - return 'The network would not accept this transaction, so nothing was sent. Build it again and retry in a moment.' + if (status.err !== null && status.err !== undefined) { + return { + ok: false, + error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: ${explorerUrl(hash)}`, + } + } + return { ok: true, hash, explorerUrl: explorerUrl(hash) } } export interface SolanaSubmitOptions { @@ -83,9 +111,20 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(RPC_TIMEOUT_MS), }) + // A node answers JSON-RPC errors with a 200. Any other status came from something + // in between, so its body can't prove what the node did. + if (!response.ok) throw new Error(`The RPC answered ${response.status}`) return RpcReply.parse(await response.json()) } + /** A status, `null` if the node answered and hasn't seen it, `undefined` if it didn't answer. */ + async function readStatus(hash: string, config?: object): Promise { + const params = config !== undefined ? [[hash], config] : [[hash]] + const reply = await rpc('getSignatureStatuses', params).catch(() => undefined) + const statuses = Statuses.safeParse(reply?.result) + return statuses.success ? statuses.data.value[0] : undefined + } + return async (input: SubmitInput): Promise => { if (input.signed.family !== 'solana') { return { ok: false, error: 'That is not a Solana transaction.' } @@ -103,23 +142,26 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { signedTransaction, { encoding: 'base64', preflightCommitment: 'confirmed' }, ]) - if (sent.error !== undefined) return { ok: false, error: explainSendError(sent.error) } + if (sent.error !== undefined) { + const refusal = explainSendError(sent.error) + if (refusal !== undefined) return { ok: false, error: refusal } + return { + ok: false, + pending: true, + error: `The network’s answer doesn’t show whether this went out, so it may still go through. Don’t send it again until you’ve checked ${explorerUrl(hash)}.`, + } + } + // A read that went unanswered may have missed the transaction landing, and one that + // saw it means it reached a block. Either way, later silence no longer proves anything. + let mayHaveLanded = false for (let poll = 0; poll < maxPolls; poll += 1) { await new Promise((resolve) => setTimeout(resolve, pollMs)) - const reply = await rpc('getSignatureStatuses', [[hash]]).catch(() => undefined) - const statuses = Statuses.safeParse(reply?.result) - const status = statuses.success ? statuses.data.value[0] : undefined - if (status === undefined || status === null) continue - if (status.err !== null && status.err !== undefined) { - return { - ok: false, - error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: ${explorerUrl(hash)}`, - } - } - if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') { - return { ok: true, hash, explorerUrl: explorerUrl(hash) } - } + const status = await readStatus(hash) + if (status === null) continue + mayHaveLanded = true + const settled = status !== undefined ? settledBy(status, hash) : undefined + if (settled !== undefined) return settled } const { blockhash } = decodeSolanaTransaction(signedTransaction) @@ -127,7 +169,14 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { () => undefined ) const valid = BlockhashValid.safeParse(validity?.result) - if (valid.success && !valid.data.value) return { ok: false, error: EXPIRED } + if (valid.success && !valid.data.value) { + // An expired blockhash only rules out the future, so this read comes after it, to + // catch a transaction that landed before the blockhash ran out. + const last = await readStatus(hash, { searchTransactionHistory: true }) + if (last === null && !mayHaveLanded) return { ok: false, error: EXPIRED } + const settled = last !== null && last !== undefined ? settledBy(last, hash) : undefined + if (settled !== undefined) return settled + } return { ok: false, pending: true, From 1a5fe73b693282a76b443cd7a207770d84f8e84e Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:57:44 +0100 Subject: [PATCH 12/24] feat(dapp): race jupiter and cetus against 1click Solana and Sui join the agent race. 1Click is a real second swap route there: a dry quote in the race, a real one only at Review, and one transfer to its deposit address for USDC or USDT. The browser carries context back at submit, so the deposit it names is signed by this server and checked in assertSafe, which now receives it. A swap that fails at 1Click after the transfer is pending, never clean. --- apps/dapp/.env.example | 1 + .../app/api/chain/[chain]/submit/route.ts | 4 +- .../lib/__tests__/oneclick-origin.test.ts | 983 ++++++++++++++++++ .../orchestrate-capabilities-chain.test.ts | 6 +- .../__tests__/solana-sui-race-quote.test.ts | 436 ++++++++ apps/dapp/lib/__tests__/solana-venues.test.ts | 18 +- apps/dapp/lib/__tests__/sui-module.test.ts | 7 +- apps/dapp/lib/chains/near/oneclick.ts | 14 +- apps/dapp/lib/chains/near/swap.ts | 133 ++- apps/dapp/lib/chains/oneclick-origin.ts | 528 ++++++++++ apps/dapp/lib/chains/prepare.ts | 2 +- apps/dapp/lib/chains/solana/jupiter-lend.ts | 20 +- apps/dapp/lib/chains/solana/jupiter-swap.ts | 31 +- apps/dapp/lib/chains/solana/kamino.ts | 19 +- apps/dapp/lib/chains/solana/lulo.ts | 20 +- apps/dapp/lib/chains/solana/module.ts | 2 + apps/dapp/lib/chains/solana/venues.ts | 12 + apps/dapp/lib/chains/sui/cetus.ts | 19 +- apps/dapp/lib/chains/sui/module.ts | 3 +- apps/dapp/lib/chains/sui/navi.ts | 16 +- apps/dapp/lib/chains/sui/springsui.ts | 17 +- apps/dapp/lib/chains/sui/venues.ts | 12 + apps/dapp/lib/chains/types.ts | 17 +- apps/dapp/lib/orchestrate/capabilities.ts | 2 +- apps/dapp/lib/weave/deposit/solana.ts | 68 +- apps/dapp/lib/weave/deposit/sui.ts | 73 +- apps/dapp/lib/weave/types.ts | 6 +- .../public/images/venues/1click-solana.webp | Bin 0 -> 1234 bytes .../dapp/public/images/venues/1click-sui.webp | Bin 0 -> 1234 bytes 29 files changed, 2343 insertions(+), 126 deletions(-) create mode 100644 apps/dapp/lib/__tests__/oneclick-origin.test.ts create mode 100644 apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts create mode 100644 apps/dapp/lib/chains/oneclick-origin.ts create mode 100644 apps/dapp/public/images/venues/1click-solana.webp create mode 100644 apps/dapp/public/images/venues/1click-sui.webp diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 92a45075..4a7403ca 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -69,6 +69,7 @@ MONEYGRAM_PRODUCTION_HOME_DOMAIN= # the transaction pooler, port 6543); localhost only works on your own machine. DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent # Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Also signs the 1Click deposits issued on Solana and Sui. Without it that route isn't offered. AUTH_SECRET= # on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only # ACCESS_GATE=on turns it on, on any network. The API is outside diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index f8ffce41..e042dc9d 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -10,6 +10,8 @@ import { isWalletAddress } from '../../../../../lib/wallet/address' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +// A send waits for the network to confirm, and a 1Click swap then waits on 1Click too. +export const maxDuration = 60 const Signed = z.discriminatedUnion('family', [ z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }), @@ -65,7 +67,7 @@ export async function POST( ? { family: 'solana', transaction: signed.signedTransaction } : { family: 'sui', transaction: signed.transaction } try { - venue.assertSafe(payload, account) + venue.assertSafe(payload, account, context) } catch (e) { // This route is what sends the bytes, so any failure here means they never left. if (e instanceof ChainActionRefused) { diff --git a/apps/dapp/lib/__tests__/oneclick-origin.test.ts b/apps/dapp/lib/__tests__/oneclick-origin.test.ts new file mode 100644 index 00000000..e8bfd3a0 --- /dev/null +++ b/apps/dapp/lib/__tests__/oneclick-origin.test.ts @@ -0,0 +1,983 @@ +import { createHmac } from 'node:crypto' + +import { Inputs, Transaction } from '@mysten/sui/transactions' +import { normalizeSuiAddress, toBase64 } from '@mysten/sui/utils' +import { + AccountRole, + appendTransactionMessageInstructions, + compileTransaction, + createTransactionMessage, + getBase64EncodedWireTransaction, + getU64Encoder, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type Address, + type Blockhash, +} from '@solana/kit' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { clearTokenCache } from '../chains/near/oneclick' +import { oneClickAssetId, oneClickOriginVenue } from '../chains/oneclick-origin' +import { afterSubmitting } from '../chains/signing-flow' +import { + ASSOCIATED_TOKEN_PROGRAM, + associatedTokenAccount, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +} from '../chains/solana/assert-safe' +import { SOLANA_TOKENS } from '../chains/solana/tokens' +import { SUI_TOKENS } from '../chains/sui/tokens' +import { + ChainActionRefused, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type PreparedAction, + type SubmitResult, +} from '../chains/types' + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) + +// The real submit, without the minute of polling it would otherwise wait through. +vi.mock('../chains/solana/submit', async (original) => { + const actual = await original() + return { + ...actual, + createSolanaSubmit: () => actual.createSolanaSubmit({ pollMs: 0, maxPolls: 1, env: {} }), + } +}) + +const core = vi.hoisted(() => ({ + listCoins: vi.fn(), + getBalance: vi.fn(), + getObjects: vi.fn(), + executeTransaction: vi.fn(), + resolveTransactionPlugin: vi.fn(), +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ core }), +})) + +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) + +import { verifyQuoteSignature } from '../chains/near/quote-signature' + +const token = (list: readonly ChainToken[], symbol: string): ChainToken => + tokenBySymbol(list, symbol) as ChainToken + +const SOL_USDC = token(SOLANA_TOKENS, 'USDC') +const SOL_USDT = token(SOLANA_TOKENS, 'USDT') +const SOL_NATIVE = token(SOLANA_TOKENS, 'SOL') +const SUI_USDC = token(SUI_TOKENS, 'USDC') +const SUI_USDT = token(SUI_TOKENS, 'USDT') +const SUI_NATIVE = token(SUI_TOKENS, 'SUI') + +const ME = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM' +const DEPOSIT = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' +const THIEF = 'DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy' +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' +const UNITS = BigInt(25_000_000) + +const SUI_ME = normalizeSuiAddress('0xa1') +const SUI_DEPOSIT = normalizeSuiAddress('0xd1') +const SUI_THIEF = normalizeSuiAddress('0xdead') +const COIN_A = normalizeSuiAddress('0xc1') +const GAS_COIN = normalizeSuiAddress('0xf1') +const DIGEST = 'GKot5hBsd81kMupNCXHaqbhv3huEbxAFMLnpcX2hniwn' + +type Json = Record + +function tokenList(): Json[] { + const priceUpdatedAt = new Date().toISOString() + const entry = ( + assetId: string, + blockchain: string, + symbol: string, + decimals: number, + price: number, + contractAddress?: string + ): Json => ({ + assetId, + blockchain, + symbol, + decimals, + price, + priceUpdatedAt, + ...(contractAddress !== undefined ? { contractAddress } : {}), + }) + return [ + entry('nep141:sol-usdc.omft.near', 'sol', 'USDC', 6, 1, SOL_USDC.id), + entry('nep141:sol-usdt.omft.near', 'sol', 'USDT', 6, 1, SOL_USDT.id), + entry('nep141:sol.omft.near', 'sol', 'SOL', 9, 100), + // A look-alike: the right symbol on the right chain, on another contract. + entry('nep141:sol-fake.omft.near', 'sol', 'USDC', 6, 1, THIEF), + entry('nep141:sui-usdc.omft.near', 'sui', 'USDC', 6, 1, SUI_USDC.id.replace('0xdba', '0xDBA')), + entry('nep141:sui-usdt.omft.near', 'sui', 'USDT', 6, 1, SUI_USDT.id), + entry('nep141:sui.omft.near', 'sui', 'SUI', 9, 2), + entry('nep141:eth-usdc.omft.near', 'eth', 'USDC', 6, 1, SOL_USDC.id), + ] +} + +interface Sim { + tokens: Json[] + quotes: Json[] + notified: Json[] + rpc: string[] + statuses: (string | undefined)[] + statusRecipient: string | undefined + quoteOverride: (body: Json) => Json | undefined + send: () => unknown + depositAddress: string +} + +let sim: Sim + +function goodQuote(body: Json): Json { + const amount = body['amount'] as string + // Priced to match the list: SOL at $100 and SUI at $2, against dollars with six places. + const scale: Record = { + 'nep141:sol.omft.near': 10, + 'nep141:sui.omft.near': 500, + } + const out = BigInt(amount) * BigInt(scale[body['destinationAsset'] as string] ?? 1) + return { + quote: { + amountIn: amount, + amountInFormatted: '25', + amountInUsd: String(Number(amount) / 1e6), + minAmountIn: amount, + amountOut: out.toString(), + amountOutFormatted: 'x', + amountOutUsd: String((Number(amount) / 1e6) * 0.995), + minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(), + ...(body['dry'] === true ? {} : { depositAddress: sim.depositAddress }), + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + } +} + +function installFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status }) + if (url.endsWith('/v0/tokens')) return json(sim.tokens) + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Json + sim.quotes.push(body) + return json(sim.quoteOverride(body) ?? goodQuote(body), 201) + } + if (url.endsWith('/v0/deposit/submit')) { + sim.notified.push(JSON.parse(init?.body as string) as Json) + return json({ correlationId: 'c' }) + } + if (url.includes('/v0/status')) { + const status = sim.statuses.length > 1 ? sim.statuses.shift() : sim.statuses[0] + return json({ + status, + quoteResponse: { quoteRequest: { recipient: sim.statusRecipient } }, + }) + } + const { method } = JSON.parse(init?.body as string) as { method: string } + sim.rpc.push(method) + const replies: Record = { + getAccountInfo: { result: { context: { slot: 1 }, value: null } }, + getLatestBlockhash: { + result: { + context: { slot: 1 }, + value: { blockhash: BLOCKHASH, lastValidBlockHeight: 9 }, + }, + }, + sendTransaction: method === 'sendTransaction' ? await sim.send() : undefined, + getSignatureStatuses: { + result: { value: [{ confirmationStatus: 'confirmed', err: null }] }, + }, + isBlockhashValid: { result: { value: true } }, + } + return json({ jsonrpc: '2.0', id: 1, ...(replies[method] as object) }) + }) + ) +} + +const swap = (over: Partial = {}): ChainActionRequest => ({ + kind: 'swap', + amount: '25', + tokenIn: 'USDC', + tokenOut: 'SOL', + ...over, +}) + +const suiSwap = (over: Partial = {}): ChainActionRequest => + swap({ tokenOut: 'SUI', ...over }) + +const solana = oneClickOriginVenue('solana') +const sui = oneClickOriginVenue('sui') + +interface TransferFields { + mint?: string + to?: string + amount?: bigint + feePayer?: string + extra?: boolean + owner?: string +} + +/** A signed-shaped Solana transaction with chosen fields, the way a tampered browser would. */ +function solanaTx(fields: TransferFields = {}): string { + const mint = fields.mint ?? SOL_USDC.id + const to = fields.to ?? DEPOSIT + const owner = fields.owner ?? ME + const destination = associatedTokenAccount(to, mint) + const transfer = (amount: bigint, target: string) => ({ + programAddress: TOKEN_PROGRAM as Address, + accounts: [ + { address: associatedTokenAccount(owner, mint) as Address, role: AccountRole.WRITABLE }, + { address: mint as Address, role: AccountRole.READONLY }, + { address: target as Address, role: AccountRole.WRITABLE }, + { address: owner as Address, role: AccountRole.READONLY_SIGNER }, + ], + data: Uint8Array.from([12, ...getU64Encoder().encode(amount), 6]), + }) + const instructions = [ + { + programAddress: ASSOCIATED_TOKEN_PROGRAM as Address, + accounts: [ + { address: owner as Address, role: AccountRole.WRITABLE_SIGNER }, + { address: destination as Address, role: AccountRole.WRITABLE }, + { address: to as Address, role: AccountRole.READONLY }, + { address: mint as Address, role: AccountRole.READONLY }, + { address: SYSTEM_PROGRAM as Address, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM as Address, role: AccountRole.READONLY }, + ], + data: Uint8Array.of(1), + }, + transfer(fields.amount ?? UNITS, destination), + ...(fields.extra === true ? [transfer(BigInt(1), associatedTokenAccount(THIEF, mint))] : []), + ] + const message = pipe( + createTransactionMessage({ version: 'legacy' }), + (m) => setTransactionMessageFeePayer((fields.feePayer ?? owner) as Address, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: BLOCKHASH as Blockhash, lastValidBlockHeight: BigInt(9) }, + m + ), + (m) => appendTransactionMessageInstructions(instructions as never, m) + ) + return getBase64EncodedWireTransaction(compileTransaction(message as never)) +} + +/** What a wallet hands back: the same bytes with the fee payer's signature filled in. */ +function signedBytes(base64: string): string { + const bytes = Buffer.from(base64, 'base64') + bytes.fill(7, 1, 65) + return bytes.toString('base64') +} + +function suiTx(fields: { to?: string; amount?: bigint; gasOwner?: string; extra?: boolean } = {}) { + const tx = new Transaction() + tx.setSender(SUI_ME) + tx.setGasOwner(fields.gasOwner ?? SUI_ME) + tx.setGasPrice(1000) + tx.setGasBudget(1_000_000) + tx.setGasPayment([{ objectId: GAS_COIN, version: '10', digest: DIGEST }]) + const coin = tx.object(Inputs.ObjectRef({ objectId: COIN_A, version: '10', digest: DIGEST })) + const [split] = tx.splitCoins(coin, [fields.amount ?? UNITS]) + tx.transferObjects([split as never], fields.to ?? SUI_DEPOSIT) + if (fields.extra === true) { + const [more] = tx.splitCoins(coin, [BigInt(1)]) + tx.transferObjects([more as never], SUI_THIEF) + } + return tx.build().then(toBase64) +} + +function coinObject(objectId: string, type = SUI_USDC.id, owner = SUI_ME) { + return { + objectId, + version: '10', + digest: DIGEST, + owner: { $kind: 'AddressOwner', AddressOwner: owner }, + type: `0x2::coin::Coin<${type}>`, + } +} + +beforeEach(() => { + clearTokenCache() + sim = { + tokens: tokenList(), + quotes: [], + notified: [], + rpc: [], + statuses: ['SUCCESS'], + statusRecipient: ME, + quoteOverride: () => undefined, + send: () => ({ result: 'sent' }), + depositAddress: DEPOSIT, + } + installFetch() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) + vi.mocked(verifyQuoteSignature).mockReturnValue(true) + for (const mock of Object.values(core)) mock.mockReset() + core.resolveTransactionPlugin.mockReturnValue( + async ( + data: { gasData: { price: unknown; budget: unknown; payment: unknown } }, + _options: unknown, + next: () => Promise + ) => { + data.gasData.price = '1000' + data.gasData.budget = '1000000' + data.gasData.payment = [{ objectId: GAS_COIN, version: '10', digest: DIGEST }] + await next() + } + ) + core.getObjects.mockImplementation(async ({ objectIds }: { objectIds: string[] }) => ({ + objects: objectIds.map((id) => coinObject(id)), + })) + core.listCoins.mockImplementation(async ({ coinType }: { coinType: string }) => ({ + objects: [{ ...coinObject(COIN_A, coinType), balance: '90000000' }], + hasNextPage: false, + cursor: null, + })) + core.executeTransaction.mockResolvedValue({ + Transaction: { digest: 'DIGESTx', effects: { status: { success: true } } }, + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.useRealTimers() +}) + +describe('oneClickAssetId', () => { + it('matches on chain and contract', async () => { + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBe('nep141:sol-usdc.omft.near') + await expect(oneClickAssetId('solana', SOL_USDT)).resolves.toBe('nep141:sol-usdt.omft.near') + }) + + it('refuses a look-alike symbol on another contract', async () => { + const lookAlike = { + symbol: 'USDC', + id: 'So11111111111111111111111111111111111111113', + decimals: 6, + } + await expect(oneClickAssetId('solana', lookAlike)).resolves.toBeUndefined() + }) + + it('refuses the right contract when the list has it only on another chain', async () => { + sim.tokens = tokenList().filter((t) => t['blockchain'] !== 'sol') + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('never folds case on a base58 mint, where case is the address', async () => { + sim.tokens = [{ ...tokenList()[0], contractAddress: SOL_USDC.id.toLowerCase() }] + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('reads hex in any case', async () => { + await expect(oneClickAssetId('sui', SUI_USDC)).resolves.toBe('nep141:sui-usdc.omft.near') + }) + + it('refuses an entry whose decimals disagree with the token', async () => { + sim.tokens = [{ ...tokenList()[0], decimals: 9 }] + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('refuses a contract the list carries twice, since it could not say which is real', async () => { + const first = tokenList()[0] as Json + sim.tokens = [first, { ...first, assetId: 'nep141:other.omft.near' }] + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('finds a native coin listed without a contract, but only the native coin', async () => { + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBe('nep141:sui.omft.near') + sim.tokens = [{ ...tokenList()[2], symbol: 'USDC', decimals: 6 }] + clearTokenCache() + await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() + }) + + it('finds a native coin listed under its contract too', async () => { + sim.tokens = [{ ...tokenList()[2], contractAddress: SOL_NATIVE.id }] + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') + }) + + it('still reads a list whose contract is null, so NEAR prices never break on it', async () => { + sim.tokens = [{ ...tokenList()[0] }, { ...tokenList()[2], contractAddress: null }] + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') + }) +}) + +describe('quote', () => { + it('asks only for a dry quote and gives back what the swap would return', async () => { + const quoted = await solana.quote?.(swap(), ME) + expect(quoted).toEqual({ + receive: { amount: '0.25', symbol: 'SOL' }, + valueOutUsd: 24.875, + }) + expect(sim.quotes).toHaveLength(1) + expect(sim.quotes[0]).toMatchObject({ + dry: true, + originAsset: 'nep141:sol-usdc.omft.near', + destinationAsset: 'nep141:sol.omft.near', + amount: '25000000', + recipient: ME, + recipientType: 'DESTINATION_CHAIN', + refundTo: ME, + refundType: 'ORIGIN_CHAIN', + }) + expect(JSON.stringify(quoted)).not.toContain(DEPOSIT) + }) + + it.each([ + ['solana', 'SOL', 'USDC', ME], + ['sui', 'SUI', 'USDC', SUI_ME], + ] as const)( + 'refuses native %s input in a plain sentence, before any quote', + async (chain, tokenIn, tokenOut, account) => { + const attempt = oneClickOriginVenue(chain).quote?.(swap({ tokenIn, tokenOut }), account) + await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused) + await expect(attempt).rejects.toThrow(/USDC or USDT/) + expect(sim.quotes).toHaveLength(0) + } + ) + + it('refuses any other input, such as JitoSOL', async () => { + await expect( + solana.quote?.(swap({ tokenIn: 'JitoSOL', tokenOut: 'USDC' }), ME) + ).rejects.toThrow(/USDC or USDT/) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses an output 1Click does not list, so the race drops the route', async () => { + await expect(solana.quote?.(swap({ tokenOut: 'ONyc' }), ME)).rejects.toThrow(/ONyc/) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses USDT on Sui when 1Click does not list it', async () => { + sim.tokens = tokenList().filter((t) => t['assetId'] !== 'nep141:sui-usdt.omft.near') + await expect(sui.quote?.(suiSwap({ tokenIn: 'USDT' }), SUI_ME)).rejects.toBeInstanceOf( + ChainActionRefused + ) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses another chain’s account, and a swap to the same token', async () => { + await expect(solana.quote?.(swap(), 'alice.near')).rejects.toBeInstanceOf(ChainActionRefused) + await expect(solana.quote?.(swap({ tokenOut: 'USDC' }), ME)).rejects.toThrow(/same token/) + }) + + it('offers no route when there is no secret to protect what it would issue', async () => { + vi.stubEnv('AUTH_SECRET', '') + await expect(solana.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.quotes).toHaveLength(0) + }) + + it('refuses plainly on a chain whose deposits are not built yet', async () => { + const avalanche = oneClickOriginVenue('avalanche') + expect(avalanche.id).toBe('1click-avalanche') + await expect(avalanche.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + await expect(avalanche.prepare(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(() => avalanche.assertSafe({ family: 'solana', transaction: 'AAAA' }, ME)).toThrow( + ChainActionRefused + ) + }) +}) + +describe('prepare', () => { + it('runs a dry quote and only then a real one, with a five-minute deadline', async () => { + await solana.prepare(swap(), ME) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true, false]) + const minutes = (Date.parse(sim.quotes[1]?.['deadline'] as string) - Date.now()) / 60_000 + expect(minutes).toBeGreaterThan(3) + expect(minutes).toBeLessThanOrEqual(5) + }) + + it('builds one transfer of exactly the quoted amount, which passes assertSafe', async () => { + const prepared = await solana.prepare(swap(), ME) + expect(prepared).toMatchObject({ + venueId: '1click-solana', + kind: 'swap', + pay: { amount: '25', symbol: 'USDC' }, + receive: { amount: '0.25', symbol: 'SOL', approximate: true }, + }) + expect(prepared.signing.family).toBe('solana') + expect(prepared.context?.['depositAddress']).toBe(DEPOSIT) + expect(() => solana.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow() + }) + + it('spends the token asked for, not USDC by assumption', async () => { + const prepared = await solana.prepare(swap({ tokenIn: 'USDT' }), ME) + expect(() => solana.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow() + if (prepared.signing.family !== 'solana') throw new Error('wrong family') + const usdtTransfer = solanaTx({ mint: SOL_USDT.id }) + expect(() => + solana.assertSafe({ family: 'solana', transaction: usdtTransfer }, ME, prepared.context) + ).not.toThrow() + expect(() => + solana.assertSafe({ family: 'solana', transaction: solanaTx() }, ME, prepared.context) + ).toThrow(ChainActionRefused) + }) + + it('refuses a deposit memo, and builds nothing', async () => { + sim.quoteOverride = (body) => { + if (body['dry'] === true) return undefined + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), depositMemo: '12345' } } + } + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/memo/i) + expect(sim.rpc).toHaveLength(0) + }) + + it.each([ + ['an amount other than what was asked', { amountIn: '24000000' }], + ['a smaller minimum in', { minAmountIn: '1' }], + ['a minimum out past the slippage allowed', { minAmountOut: '1' }], + ])('refuses a real quote with %s', async (_name, change) => { + sim.quoteOverride = (body) => { + if (body['dry'] === true) return undefined + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), ...change } } + } + await expect(solana.prepare(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.rpc).toHaveLength(0) + }) + + it.each([ + ['recipient', { recipient: THIEF }], + ['refund address', { refundTo: THIEF }], + ['refund type', { refundType: 'INTENTS' }], + ])('refuses a real quote naming another %s', async (_name, change) => { + sim.quoteOverride = (body) => + body['dry'] === true + ? undefined + : { ...goodQuote(body), quoteRequest: { ...body, ...change } } + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/didn't match/) + }) + + it('refuses a quote that fails its signature check', async () => { + vi.mocked(verifyQuoteSignature).mockReturnValue(false) + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/signature/) + }) + + it('refuses a quote worth far less than it costs', async () => { + sim.quoteOverride = (body) => { + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), amountOutUsd: '1' } } + } + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/less than you pay/) + }) + + it('refuses a deposit address that is your own wallet or not an address', async () => { + sim.depositAddress = ME + await expect(solana.prepare(swap(), ME)).rejects.toThrow(/your own wallet/) + sim.depositAddress = 'not-an-address' + await expect(solana.prepare(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + }) + + it('builds the Sui transfer from coins of the token asked for', async () => { + sim.depositAddress = SUI_DEPOSIT + sim.statusRecipient = SUI_ME + const prepared = await sui.prepare(suiSwap({ tokenIn: 'USDT' }), SUI_ME) + expect(core.listCoins.mock.calls[0]?.[0]).toMatchObject({ coinType: SUI_USDT.id }) + expect(prepared.venueId).toBe('1click-sui') + expect(() => sui.assertSafe(prepared.signing, SUI_ME, prepared.context)).not.toThrow() + }) +}) + +describe('assertSafe on Solana', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await solana.prepare(swap(), ME) + }) + + const check = (transaction: string, context = prepared.context, account = ME): void => + solana.assertSafe({ family: 'solana', transaction }, account, context) + + it('admits the one transfer that was issued', () => { + expect(() => check(solanaTx())).not.toThrow() + }) + + it.each<[string, TransferFields]>([ + ['a wrong recipient', { to: THIEF }], + ['a wrong amount', { amount: UNITS - BigInt(1) }], + ['an extra transfer', { extra: true }], + ['another token', { mint: SOL_USDT.id }], + ['a fee payer other than the account', { feePayer: THIEF }], + ])('refuses %s', (_name, fields) => { + expect(() => check(solanaTx(fields))).toThrow(ChainActionRefused) + }) +}) + +describe('assertSafe on Sui', () => { + let prepared: PreparedAction + + beforeEach(async () => { + sim.depositAddress = SUI_DEPOSIT + prepared = await sui.prepare(suiSwap(), SUI_ME) + }) + + const check = (transaction: string): void => + sui.assertSafe({ family: 'sui', transaction }, SUI_ME, prepared.context) + + it('admits the one transfer that was issued', async () => { + const bytes = await suiTx() + expect(() => check(bytes)).not.toThrow() + }) + + it.each([ + ['a wrong recipient', { to: SUI_THIEF }], + ['a wrong amount', { amount: UNITS - BigInt(1) }], + ['an extra transfer', { extra: true }], + ['a gas owner other than the account', { gasOwner: SUI_THIEF }], + ])('refuses %s', async (_name, fields) => { + const bytes = await suiTx(fields) + expect(() => check(bytes)).toThrow(ChainActionRefused) + }) +}) + +describe('a browser that changes what it sends back', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await solana.prepare(swap(), ME) + }) + + const payload = (fields: TransferFields = {}) => + ({ family: 'solana', transaction: solanaTx(fields) }) as const + + it('cannot point the deposit at its own address', () => { + const context = { ...prepared.context, depositAddress: THIEF } + expect(() => solana.assertSafe(payload({ to: THIEF }), ME, context)).toThrow(ChainActionRefused) + }) + + it('cannot change the amount or the token with the bytes to match', () => { + const smaller = { ...prepared.context, amount: '1' } + expect(() => solana.assertSafe(payload({ amount: BigInt(1) }), ME, smaller)).toThrow( + ChainActionRefused + ) + const otherToken = { ...prepared.context, token: SOL_USDT.id } + expect(() => solana.assertSafe(payload({ mint: SOL_USDT.id }), ME, otherToken)).toThrow( + ChainActionRefused + ) + }) + + it('cannot extend the deadline', () => { + const later = { ...prepared.context, deadline: '2099-01-01T00:00:00.000Z' } + expect(() => solana.assertSafe(payload(), ME, later)).toThrow(ChainActionRefused) + }) + + it('cannot leave the context out or empty', () => { + expect(() => solana.assertSafe(payload(), ME)).toThrow(ChainActionRefused) + expect(() => solana.assertSafe(payload(), ME, {})).toThrow(ChainActionRefused) + }) + + it('cannot forge a proof without the server’s secret', () => { + const context: Record = { ...prepared.context, depositAddress: THIEF } + const issued = JSON.stringify([ + '1click-solana', + ME, + SOL_USDC.id, + '25000000', + THIEF, + context['deadline'], + ]) + const proof = createHmac('sha256', 'y'.repeat(48)).update(issued).digest('base64url') + expect(() => solana.assertSafe(payload({ to: THIEF }), ME, { ...context, proof })).toThrow( + ChainActionRefused + ) + }) + + it('cannot reuse a proof for another account or another chain', async () => { + // Bytes that are a perfect transfer from the other account, so only the proof can refuse them. + expect(() => solana.assertSafe(payload({ owner: THIEF }), THIEF, prepared.context)).toThrow( + /not what this app issued/ + ) + expect(() => + sui.assertSafe({ family: 'sui', transaction: 'AAAA' }, SUI_ME, prepared.context) + ).toThrow(/not what this app issued/) + }) + + it('cannot spend a quote after its deadline', () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(Date.now() + 6 * 60_000) + expect(() => solana.assertSafe(payload(), ME, prepared.context)).toThrow(/expired/) + }) + + it('is refused when the server no longer holds the secret', () => { + vi.stubEnv('AUTH_SECRET', '') + expect(() => solana.assertSafe(payload(), ME, prepared.context)).toThrow(ChainActionRefused) + }) + + it('sends nothing from submit either', async () => { + const context = { ...prepared.context, depositAddress: THIEF } + const result = await solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx({ to: THIEF })) }, + context, + }) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(sim.rpc).not.toContain('sendTransaction') + }) + + it('is stopped by the real route, which hands assertSafe the context', async () => { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + const res = await POST( + new Request('http://localhost/api/chain/solana/submit', { + method: 'POST', + body: JSON.stringify({ + account: ME, + venueId: '1click-solana', + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx({ to: THIEF })) }, + context: { ...prepared.context, depositAddress: THIEF }, + }), + }), + { params: Promise.resolve({ chain: 'solana' }) } + ) + expect(res.status).toBe(422) + expect(sim.rpc).not.toContain('sendTransaction') + }) +}) + +describe('the submit route', () => { + it('gives a submit the 60 seconds it waits within', async () => { + const route = await import('../../app/api/chain/[chain]/submit/route') + expect(route.maxDuration).toBe(60) + }) + + it('hands assertSafe the context, so an honest swap gets through', async () => { + const prepared = await solana.prepare(swap(), ME) + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + const res = await POST( + new Request('http://localhost/api/chain/solana/submit', { + method: 'POST', + body: JSON.stringify({ + account: ME, + venueId: '1click-solana', + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx()) }, + context: prepared.context, + }), + }), + { params: Promise.resolve({ chain: 'solana' }) } + ) + expect(res.status).toBe(200) + expect(sim.rpc).toContain('sendTransaction') + }) +}) + +describe('submit on Solana', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await solana.prepare(swap(), ME) + }) + + async function run(fields: TransferFields = {}): Promise { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const pending = solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx(fields)) }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.runAllTimersAsync() + return pending + } + + it('returns ok once 1Click reports success, and tells it about the deposit', async () => { + const result = await run() + expect(result).toMatchObject({ ok: true }) + expect(sim.rpc).toContain('sendTransaction') + expect(sim.notified).toHaveLength(1) + expect(sim.notified[0]).toMatchObject({ depositAddress: DEPOSIT }) + expect(sim.notified[0]).not.toHaveProperty('nearSenderAccount') + }) + + it('keeps following while the swap runs', async () => { + sim.statuses = ['PENDING_DEPOSIT', 'PROCESSING', 'SUCCESS'] + await expect(run()).resolves.toMatchObject({ ok: true }) + }) + + it.each([ + ['a swap still running', 'PROCESSING', /still processing/i], + ['a swap not seen yet', 'PENDING_DEPOSIT', /still processing/i], + ['a refund', 'REFUNDED', new RegExp(ME)], + ['a failed swap', 'FAILED', new RegExp(ME)], + ['too little received', 'INCOMPLETE_DEPOSIT', new RegExp(ME)], + ['an unknown status', 'SOMETHING_NEW', /still processing/i], + ])('reports %s as pending, holding the latch', async (_name, status, sentence) => { + sim.statuses = [status] + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + if (result.ok) return + expect(result.error).toMatch(sentence) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('reports a deposit address 1Click ties to someone else as pending', async () => { + sim.statusRecipient = THIEF + await expect(run()).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it('stops following within the route’s 60 seconds', async () => { + sim.statuses = ['PROCESSING'] + const startedAt = Date.now() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: startedAt }) + const pending = solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx()) }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.runAllTimersAsync() + await pending + expect(Date.now() - startedAt).toBeLessThan(60_000) + }) + + it('answers pending if the network has not answered by the time limit', async () => { + sim.send = () => new Promise(() => undefined) + const startedAt = Date.now() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: startedAt }) + const pending = solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: signedBytes(solanaTx()) }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.advanceTimersByTimeAsync(55_000) + const result = await pending + expect(result).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + expect(sim.notified).toHaveLength(0) + }) + + it('passes on a clean failure the network proved, without asking 1Click', async () => { + sim.send = () => ({ error: { code: -32002, message: 'simulation failed', data: {} } }) + const result = await run() + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'failed', latched: false }) + expect(sim.notified).toHaveLength(0) + }) + + it('reports an unclear send answer as pending, without asking 1Click', async () => { + sim.send = () => ({ error: { code: -32000, message: 'gateway said something' } }) + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toContain(DEPOSIT) + expect(sim.notified).toHaveLength(0) + }) + + it('reports a dropped connection during the send as pending', async () => { + sim.send = () => { + throw new Error('connection reset') + } + await expect(run()).resolves.toMatchObject({ ok: false, pending: true }) + }) + + it('sends nothing the wallet left unsigned', async () => { + const result = await solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'solana', signedTransaction: solanaTx() }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(sim.rpc).not.toContain('sendTransaction') + }) + + it('refuses another chain’s signature without sending', async () => { + const result = await solana.submit?.({ + account: ME, + venueId: solana.id, + signed: { family: 'sui', transaction: 'AAAA', signature: 'sig' }, + }) + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + }) +}) + +describe('submit on Sui', () => { + let prepared: PreparedAction + + beforeEach(async () => { + sim.depositAddress = SUI_DEPOSIT + sim.statusRecipient = SUI_ME + prepared = await sui.prepare(suiSwap(), SUI_ME) + }) + + async function run(): Promise { + const transaction = await suiTx() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const pending = sui.submit?.({ + account: SUI_ME, + venueId: sui.id, + signed: { family: 'sui', transaction, signature: 'sig' }, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }) as Promise + await vi.runAllTimersAsync() + return pending + } + + it('returns ok on success, with the Sui explorer link', async () => { + const result = await run() + expect(result).toEqual({ + ok: true, + hash: 'DIGESTx', + explorerUrl: 'https://suiscan.xyz/mainnet/tx/DIGESTx', + }) + expect(sim.notified[0]).toMatchObject({ txHash: 'DIGESTx', depositAddress: SUI_DEPOSIT }) + }) + + it('refuses a coin of another type, which the bytes cannot show, without sending', async () => { + core.getObjects.mockResolvedValue({ objects: [coinObject(COIN_A, SUI_USDT.id)] }) + const result = await run() + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + expect(afterSubmitting(result, 'sui')).toMatchObject({ phase: 'failed', latched: false }) + expect(core.executeTransaction).not.toHaveBeenCalled() + }) + + it('passes on a failure the network proved, in the words of the token', async () => { + core.executeTransaction.mockResolvedValue({ + FailedTransaction: { digest: 'DIGESTx', effects: { status: { success: false } } }, + }) + const result = await run() + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') + if (!result.ok) expect(result.error).toMatch(/USDC did not move/) + expect(sim.notified).toHaveLength(0) + }) + + it('reports a broken connection during the send as pending', async () => { + core.executeTransaction.mockRejectedValue(new Error('deadline exceeded')) + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(result, 'sui')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it.each(['REFUNDED', 'FAILED', 'INCOMPLETE_DEPOSIT'])( + 'reports %s after the transfer as pending, naming where funds return', + async (status) => { + sim.statuses = [status] + const result = await run() + expect(result).toMatchObject({ ok: false, pending: true }) + if (!result.ok) expect(result.error).toContain(SUI_ME) + } + ) +}) diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index 1604f491..fd0e425a 100644 --- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -18,9 +18,9 @@ describe('capabilitySheet per chain', () => { expect(sheet).not.toMatch(/Send:|Accumulate|Hedge|limit order/) }) - it('does not promise a race on Solana or Sui before their venues can be quoted', () => { - expect(capabilitySheet('solana')).not.toContain('Agents compete') - expect(capabilitySheet('sui')).not.toContain('Agents compete') + it('says agents compete on Solana and Sui now their venues can be quoted', () => { + expect(capabilitySheet('solana')).toContain('Agents compete') + expect(capabilitySheet('sui')).toContain('Agents compete') expect(capabilitySheet('solana')).not.toContain('Stake:') }) }) diff --git a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts new file mode 100644 index 00000000..b8fb06ef --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts @@ -0,0 +1,436 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { quoteChainRoutes } from '../agents/chain-routes' +import { clearTokenCache } from '../chains/near/oneclick' +import { oneClickOriginVenue } from '../chains/oneclick-origin' +import { + CAPTURE_ACCOUNT, + JUPITER_SOL_TO_USDC, +} from '../chains/solana/__fixtures__/captured-transactions' +import { createJupiterLendVenue } from '../chains/solana/jupiter-lend' +import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap' +import { createKaminoVenue } from '../chains/solana/kamino' +import { createLuloVenue } from '../chains/solana/lulo' +import { buildSolanaVenues, solanaModule } from '../chains/solana/module' +import { solanaVenues } from '../chains/solana/venues' +import { cetusVenue } from '../chains/sui/cetus' +import { suiModule } from '../chains/sui/module' +import { naviVenue } from '../chains/sui/navi' +import { springsuiVenue } from '../chains/sui/springsui' +import { suiVenues } from '../chains/sui/venues' +import { ChainActionRefused, type ChainModule } from '../chains/types' +import { capabilitySheet } from '../orchestrate/capabilities' + +const sui = vi.hoisted(() => ({ + findRouters: vi.fn(), + fastRouterSwap: vi.fn(), + getPools: vi.fn(), + depositCoinPTB: vi.fn(), + simulateTransaction: vi.fn(), + buildUnsigned: vi.fn(), +})) + +vi.mock('@cetusprotocol/aggregator-sdk', () => ({ + AggregatorClient: class { + findRouters = sui.findRouters + fastRouterSwap = sui.fastRouterSwap + }, +})) + +vi.mock('@naviprotocol/lending', () => ({ + getPools: sui.getPools, + depositCoinPTB: sui.depositCoinPTB, +})) + +vi.mock('../chains/sui/client', async (original) => ({ + ...(await original()), + suiClient: () => ({ core: { simulateTransaction: sui.simulateTransaction } }), + buildUnsigned: sui.buildUnsigned, +})) + +vi.mock('../chains/sui/price', () => ({ + priceFromLlama: async (token: { symbol: string }) => (token.symbol === 'SUI' ? 2 : 1), +})) + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) + +const SOL = 'So11111111111111111111111111111111111111112' +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +const SUI_ME = `0x${'a1'.padStart(64, '0')}` +const USDC_SUI = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' + +type Route = (url: string, init?: RequestInit) => { status?: number; body: unknown } | undefined + +function stubFetch(route: Route): { calls: { url: string; init?: RequestInit }[] } { + const calls: { url: string; init?: RequestInit }[] = [] + vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => { + const url = String(input) + calls.push({ url, ...(init !== undefined ? { init } : {}) }) + const answer = route(url, init) + if (answer === undefined) throw new Error(`unexpected fetch ${url}`) + return new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 }) + }) + return { calls } +} + +beforeEach(() => { + clearTokenCache() + for (const mock of Object.values(sui)) mock.mockReset() + vi.stubEnv('AUTH_SECRET', 'x'.repeat(48)) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('Jupiter quote', () => { + const order = { + inputMint: SOL, + outputMint: USDC, + inAmount: '10000000', + outAmount: '931580', + otherAmountThreshold: '922264', + slippageBps: 100, + priceImpact: -0.0097, + router: 'metis', + gasless: false, + signatureFeePayer: CAPTURE_ACCOUNT, + requestId: 'request-1', + taker: CAPTURE_ACCOUNT, + transaction: JUPITER_SOL_TO_USDC, + } + const request = { kind: 'swap', amount: '0.01', tokenIn: 'SOL', tokenOut: 'USDC' } as const + const venue = () => + createJupiterSwapVenue({ + priceUsd: async (t) => (t.symbol === 'SOL' ? 93 : 1), + env: {}, + retryDelayMs: 0, + }) + + it('asks /swap/v2/order as the account and returns what comes back, not the transaction', async () => { + const { calls } = stubFetch(() => ({ body: order })) + const quoted = await venue().quote?.(request, CAPTURE_ACCOUNT) + expect(quoted).toEqual({ receive: { amount: '0.93158', symbol: 'USDC' } }) + expect(quoted).not.toHaveProperty('transaction') + expect(JSON.stringify(quoted)).not.toContain(JUPITER_SOL_TO_USDC) + const url = new URL(calls[0]?.url ?? '') + expect(url.origin + url.pathname).toBe('https://api.jup.ag/swap/v2/order') + expect(url.searchParams.get('taker')).toBe(CAPTURE_ACCOUNT) + expect(calls).toHaveLength(1) + }) + + it('refuses what prepare would refuse, so the race never offers it', async () => { + stubFetch(() => ({ body: { ...order, priceImpact: -3.5 } })) + await expect(venue().quote?.(request, CAPTURE_ACCOUNT)).rejects.toThrow(/price impact/i) + stubFetch(() => ({ body: { ...order, router: 'unknown' } })) + await expect(venue().quote?.(request, CAPTURE_ACCOUNT)).rejects.toBeInstanceOf( + ChainActionRefused + ) + }) +}) + +describe('Solana supply quotes', () => { + const supply = { kind: 'supply', amount: '10', tokenIn: 'USDC' } as const + + it('gives Jupiter Lend’s rate from its own reader, without building a deposit', async () => { + const { calls } = stubFetch(() => ({ + body: [{ assetAddress: USDC, supplyRate: '504', rewardsRate: '100' }], + })) + const quoted = await createJupiterLendVenue({ env: {} }).quote?.(supply, CAPTURE_ACCOUNT) + expect(quoted).toEqual({ apyPercent: 6.04 }) + expect(calls.every((c) => !c.url.includes('/earn/deposit'))).toBe(true) + }) + + it('leaves the rate out when it cannot be read, rather than inventing one', async () => { + stubFetch(() => ({ status: 500, body: {} })) + await expect( + createJupiterLendVenue({ env: {} }).quote?.(supply, CAPTURE_ACCOUNT) + ).resolves.toEqual({}) + }) + + it('refuses a token Jupiter Lend would refuse', async () => { + await expect( + createJupiterLendVenue({ env: {} }).quote?.({ ...supply, tokenIn: 'SOL' }, CAPTURE_ACCOUNT) + ).rejects.toThrow(/USDC or USDT/) + }) + + const vault = { + address: 'vault-1', + state: { name: 'Prime', tokenMint: USDC, prevAum: '1000', minDepositAmount: '1000000' }, + } + + it('gives Kamino’s rate for the vault it would use', async () => { + const { calls } = stubFetch((url) => + url.endsWith('/kvaults/vaults') + ? { body: [vault] } + : { body: { apy: '0.05', apyFarmRewards: '0.01' } } + ) + const quoted = await createKaminoVenue({ cacheMs: 0 }).quote?.(supply, CAPTURE_ACCOUNT) + expect(quoted?.apyPercent).toBeCloseTo(6, 6) + expect(calls.every((c) => !c.url.includes('/ktx/'))).toBe(true) + }) + + it('refuses an amount under Kamino’s vault minimum, as prepare would', async () => { + stubFetch(() => ({ body: [vault] })) + await expect( + createKaminoVenue({ cacheMs: 0 }).quote?.({ ...supply, amount: '0.5' }, CAPTURE_ACCOUNT) + ).rejects.toThrow(/at least/) + }) + + it('leaves Kamino’s rate out when its metrics cannot be read', async () => { + stubFetch((url) => + url.endsWith('/kvaults/vaults') ? { body: [vault] } : { status: 500, body: {} } + ) + await expect( + createKaminoVenue({ cacheMs: 0 }).quote?.(supply, CAPTURE_ACCOUNT) + ).resolves.toEqual({}) + }) + + it('gives Lulo’s rate, without building a deposit', async () => { + const { calls } = stubFetch(() => ({ body: { protected: { CURRENT: 7.5 } } })) + await expect( + createLuloVenue({ apiKey: 'k' }).quote?.(supply, CAPTURE_ACCOUNT) + ).resolves.toEqual({ apyPercent: 7.5 }) + expect(calls.every((c) => !c.url.includes('deposit'))).toBe(true) + }) + + it('refuses a token Lulo would refuse', async () => { + await expect( + createLuloVenue({ apiKey: 'k' }).quote?.({ ...supply, tokenIn: 'USDT' }, CAPTURE_ACCOUNT) + ).rejects.toThrow(/USDC deposits only/) + }) +}) + +describe('Cetus quote', () => { + const request = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' } as const + const route = { + amountIn: { toString: () => '10000000' }, + amountOut: { toString: () => '4950000000' }, + deviationRatio: '0.001', + paths: [{}], + } + + it('returns its best route without building a transaction', async () => { + sui.findRouters.mockResolvedValue(route) + await expect(cetusVenue.quote?.(request, SUI_ME)).resolves.toEqual({ + receive: { amount: '4.95', symbol: 'SUI' }, + }) + expect(sui.fastRouterSwap).not.toHaveBeenCalled() + expect(sui.buildUnsigned).not.toHaveBeenCalled() + }) + + it('refuses a route that was not found, or that prepare would refuse', async () => { + sui.findRouters.mockResolvedValue(null) + await expect(cetusVenue.quote?.(request, SUI_ME)).rejects.toThrow(/couldn't find a route/) + sui.findRouters.mockResolvedValue({ ...route, deviationRatio: '0.2' }) + await expect(cetusVenue.quote?.(request, SUI_ME)).rejects.toThrow(/move the price/) + }) +}) + +describe('NAVI quote', () => { + const supply = { kind: 'supply', amount: '10', tokenIn: 'USDC' } as const + const pool = { + suiCoinType: USDC_SUI, + isDeprecated: false, + leftSupply: '1000', + totalSupplyAmount: '1', + borrowedAmount: '0', + supplyIncentiveApyInfo: { vaultApr: '8.156', boostedApr: '0.344' }, + } + + it('gives the pool’s rate from NAVI’s own fields, without building a deposit', async () => { + sui.getPools.mockResolvedValue([pool]) + const quoted = await naviVenue.quote?.(supply, SUI_ME) + expect(quoted?.apyPercent).toBeCloseTo(8.5, 6) + expect(sui.depositCoinPTB).not.toHaveBeenCalled() + expect(sui.buildUnsigned).not.toHaveBeenCalled() + }) + + it('leaves the rate out when NAVI gives none', async () => { + sui.getPools.mockResolvedValue([{ ...pool, supplyIncentiveApyInfo: {} }]) + await expect(naviVenue.quote?.(supply, SUI_ME)).resolves.toEqual({}) + }) + + it('refuses a closed pool, a full pool and another token', async () => { + sui.getPools.mockResolvedValue([{ ...pool, isDeprecated: true }]) + await expect(naviVenue.quote?.(supply, SUI_ME)).rejects.toThrow(/isn't open/) + sui.getPools.mockResolvedValue([{ ...pool, leftSupply: '5' }]) + await expect(naviVenue.quote?.(supply, SUI_ME)).rejects.toThrow(/only has room/) + await expect(naviVenue.quote?.({ ...supply, tokenIn: 'SUI' }, SUI_ME)).rejects.toThrow( + /takes USDC/ + ) + }) +}) + +describe('SpringSui quote', () => { + const stake = { kind: 'stake', amount: '1', tokenIn: 'SUI' } as const + + it('says what the stake mints, and carries no rate because none can be read', async () => { + sui.buildUnsigned.mockResolvedValue('AAAA') + const { SSUI_TYPE } = await import('../chains/sui/policies') + sui.simulateTransaction.mockResolvedValue({ + Transaction: { + balanceChanges: [{ coinType: SSUI_TYPE, address: SUI_ME, amount: '963360812' }], + }, + }) + const quoted = await springsuiVenue.quote?.(stake, SUI_ME) + expect(quoted).toEqual({ receive: { amount: '0.96336', symbol: 'sSUI' } }) + expect(quoted).not.toHaveProperty('apyPercent') + expect(JSON.stringify(quoted)).not.toContain('AAAA') + }) + + it('refuses another token', async () => { + await expect(springsuiVenue.quote?.({ ...stake, tokenIn: 'USDC' }, SUI_ME)).rejects.toThrow( + /stakes SUI/ + ) + }) +}) + +describe('the race on these chains', () => { + const priceUpdatedAt = (): string => new Date().toISOString() + const list = () => [ + { + assetId: 'nep141:sol-usdc', + blockchain: 'sol', + symbol: 'USDC', + decimals: 6, + price: 1, + priceUpdatedAt: priceUpdatedAt(), + contractAddress: USDC, + }, + { + assetId: 'nep141:sol', + blockchain: 'sol', + symbol: 'SOL', + decimals: 9, + price: 100, + priceUpdatedAt: priceUpdatedAt(), + }, + ] + + function chainApi(): ChainModule { + return { + ...solanaModule, + priceUsd: async () => 1, + venues: [ + createJupiterSwapVenue({ + priceUsd: async (t) => (t.symbol === 'SOL' ? 100 : 1), + env: {}, + retryDelayMs: 0, + }), + oneClickOriginVenue('solana'), + ], + } + } + + function stubBoth(order: Record): Record[] { + const quotes: Record[] = [] + stubFetch((url, init) => { + if (url.includes('/swap/v2/order')) return { body: order } + if (url.endsWith('/v0/tokens')) return { body: list() } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Record + quotes.push(body) + return { + status: 201, + body: { + quote: { + amountIn: body['amount'], + amountInFormatted: '1', + amountInUsd: '1', + minAmountIn: body['amount'], + amountOut: '10000000', + amountOutFormatted: '0.01', + amountOutUsd: '0.99', + minAmountOut: '9900000', + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + }, + } + } + return undefined + }) + return quotes + } + + const baseOrder = { + slippageBps: 100, + priceImpact: -0.01, + router: 'metis', + gasless: false, + signatureFeePayer: CAPTURE_ACCOUNT, + requestId: 'r', + taker: CAPTURE_ACCOUNT, + transaction: JUPITER_SOL_TO_USDC, + } + + it('races Jupiter against 1Click on a dry quote for USDC to SOL', async () => { + const quotes = stubBoth({ + ...baseOrder, + inputMint: USDC, + outputMint: SOL, + inAmount: '1000000', + outAmount: '10000000', + otherAmountThreshold: '9900000', + }) + const routes = await quoteChainRoutes( + chainApi(), + { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SOL' }, + CAPTURE_ACCOUNT + ) + expect(routes.map((r) => r.id)).toEqual(['jupiter-1', '1click-solana-1']) + expect(quotes.map((q) => q['dry'])).toEqual([true]) + }) + + it('leaves SOL input to Jupiter alone, with 1Click dropped from the race', async () => { + const quotes = stubBoth({ + ...baseOrder, + inputMint: SOL, + outputMint: USDC, + inAmount: '10000000', + outAmount: '1000000', + otherAmountThreshold: '990000', + }) + const routes = await quoteChainRoutes( + chainApi(), + { kind: 'swap', amount: '0.01', tokenIn: 'SOL', tokenOut: 'USDC' }, + CAPTURE_ACCOUNT + ) + expect(routes.map((r) => r.id)).toEqual(['jupiter-1']) + expect(quotes).toHaveLength(0) + }) + + it('wires 1Click as a swap venue on both chains, keeping the defaults', () => { + expect(buildSolanaVenues({}).find((v) => v.id === '1click-solana')?.kinds).toEqual(['swap']) + expect(suiModule.venues.find((v) => v.id === '1click-sui')?.kinds).toEqual(['swap']) + expect(solanaModule.defaultVenue.swap).toBe('jupiter') + expect(suiModule.defaultVenue.swap).toBe('cetus') + }) + + it('gives every venue on these chains a quote to race with', () => { + const lulo = createLuloVenue({ apiKey: 'k' }) + for (const venue of [...buildSolanaVenues({}), lulo, ...suiModule.venues]) { + expect(venue.quote, venue.id).toBeTypeOf('function') + } + }) + + it('lists 1Click on the Apps page of both chains', () => { + expect(solanaVenues.find((v) => v.id === '1click-solana')?.integration).toBe('executes') + expect(suiVenues.find((v) => v.id === '1click-sui')?.integration).toBe('executes') + }) + + it('says agents compete on Solana and Sui, and nothing these chains cannot do', () => { + const solana = capabilitySheet('solana') + expect(solana).toContain('Agents compete') + expect(solana).not.toMatch(/Stake:|Send:|limit order/) + const onSui = capabilitySheet('sui') + expect(onSui).toContain('Agents compete') + expect(onSui).toContain('Stake:') + expect(onSui).not.toMatch(/Send:|limit order/) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index e70d7ade..8c845b61 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -664,15 +664,21 @@ describe('lulo', () => { }) describe('module', () => { - it('offers the three venues that need no key', () => { - expect(buildSolanaVenues({}).map((v) => v.id)).toEqual(['jupiter', 'jupiter-lend', 'kamino']) + it('offers the four venues that need no key', () => { + expect(buildSolanaVenues({}).map((v) => v.id)).toEqual([ + 'jupiter', + '1click-solana', + 'jupiter-lend', + 'kamino', + ]) expect(solanaModule.defaultVenue).toMatchObject({ swap: 'jupiter', supply: 'jupiter-lend' }) expect(solanaModule.chain).toBe('solana') }) - it('lets only Jupiter land its own transactions', () => { - const [swap, lend, kamino] = buildSolanaVenues({}) + it('lets only the swaps land their own transactions', () => { + const [swap, oneClick, lend, kamino] = buildSolanaVenues({}) expect(swap?.submit).toBeTypeOf('function') + expect(oneClick?.submit).toBeTypeOf('function') expect(lend?.submit).toBeUndefined() expect(kamino?.submit).toBeUndefined() }) @@ -732,8 +738,8 @@ describe('module', () => { describe('venues page', () => { it('lists every row of the report', () => { - expect(solanaVenues).toHaveLength(14) - expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(14) + expect(solanaVenues).toHaveLength(15) + expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(15) for (const v of solanaVenues) { expect(v.family).toBe('solana') expect(v.chains).toEqual(['Solana']) diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index 4e66d732..a9a8c2d8 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -283,9 +283,10 @@ describe('submitting signed bytes', () => { }) describe('the module', () => { - it('wires only swap, supply and stake, to Cetus, NAVI and SpringSui', () => { + it('wires only swap, supply and stake, to Cetus, 1Click, NAVI and SpringSui', () => { expect(suiModule.venues.map((v) => [v.id, v.kinds])).toEqual([ ['cetus', ['swap']], + ['1click-sui', ['swap']], ['navi', ['supply']], ['springsui', ['stake']], ]) @@ -303,8 +304,8 @@ describe('the module', () => { describe('the venue list', () => { it('has every row of the report', () => { - expect(suiVenues).toHaveLength(14) - expect(new Set(suiVenues.map((v) => v.id)).size).toBe(14) + expect(suiVenues).toHaveLength(15) + expect(new Set(suiVenues.map((v) => v.id)).size).toBe(15) }) it('is all Sui, with a real link and an honest reason', () => { diff --git a/apps/dapp/lib/chains/near/oneclick.ts b/apps/dapp/lib/chains/near/oneclick.ts index 8e8b857c..492a657f 100644 --- a/apps/dapp/lib/chains/near/oneclick.ts +++ b/apps/dapp/lib/chains/near/oneclick.ts @@ -14,6 +14,10 @@ const MAX_PRICE_AGE_MS = 15 * 60_000 /** The combined fee limit is 500 bps, and 1Click adds 25 of its own without a key. */ const MAX_APP_FEE_BPS = 475 +/** + * A NEAR token's asset id, which is its account by rule. Other chains' ids can't be worked + * out from a contract, so they come from the list through `oneClickAssetId`. + */ export function assetIdOf(token: ChainToken): string { return `nep141:${token.id}` } @@ -42,6 +46,9 @@ const TokenList = z.array( symbol: z.string(), price: z.number().optional(), priceUpdatedAt: z.string().optional(), + // Allowed to be null so that a change to these can't stop NEAR's prices from being read. + blockchain: z.string().nullish(), + contractAddress: z.string().nullish(), }) ) export type OneClickToken = z.infer[number] @@ -230,11 +237,14 @@ export async function requestQuote(input: QuoteInput): Promise { return parsed.data } -/** Optional speed-up, per the docs. A failure here changes nothing about the swap. */ +/** + * Optional speed-up, per the docs. A failure here changes nothing about the swap. The sender + * account only means something on NEAR, so other chains leave it out. + */ export async function notifyDeposit( depositAddress: string, txHash: string, - nearSenderAccount: string + nearSenderAccount?: string ): Promise { await fetch(`${BASE_URL}/deposit/submit`, { method: 'POST', diff --git a/apps/dapp/lib/chains/near/swap.ts b/apps/dapp/lib/chains/near/swap.ts index e3dfb8a0..b8f34a0c 100644 --- a/apps/dapp/lib/chains/near/swap.ts +++ b/apps/dapp/lib/chains/near/swap.ts @@ -45,15 +45,28 @@ const CONFIRM_WAIT_MS = 10_000 const STATUS_WAIT_MS = 23_000 const STATUS_POLL_MS = 2_000 -const EXPLORER_NOTE = 'You can follow it at explorer.near-intents.org by searching for' +export const EXPLORER_NOTE = 'You can follow it at explorer.near-intents.org by searching for' -interface Destination { +export const ONECLICK_RISK_NOTE = + 'NEAR Intents had an exploit of about $3.8M around 1 October 2026 in its bridge links. Funds were reported returned, but bridges and solvers it relies on remain a risk.' + +export interface Destination { assetId: string symbol: string decimals: number recipientType: 'DESTINATION_CHAIN' | 'INTENTS' } +/** What a swap spends, as 1Click names it. Off NEAR the id comes from `oneClickAssetId`. */ +export interface QuoteOrigin { + assetId: string + decimals: number +} + +function nearOrigin(token: ChainToken): QuoteOrigin { + return { assetId: assetIdOf(token), decimals: token.decimals } +} + function lossBetween(paid: number, received: number): number { return paid > 0 && Number.isFinite(paid) && Number.isFinite(received) ? 1 - received / paid @@ -68,12 +81,12 @@ function lossBetween(paid: number, received: number): number { */ async function assertFairValue( envelope: QuoteEnvelope, - tokenIn: ChainToken, + origin: QuoteOrigin, destination: Destination ): Promise { const { quote } = envelope const [priceIn, priceOut] = await Promise.all([ - oneClickPrice(assetIdOf(tokenIn), tokenIn.decimals), + oneClickPrice(origin.assetId, origin.decimals), oneClickPrice(destination.assetId, destination.decimals), ]) const quoted = lossBetween(Number(quote.amountInUsd), Number(quote.amountOutUsd)) @@ -81,7 +94,7 @@ async function assertFairValue( priceIn === undefined || priceOut === undefined ? Number.NaN : lossBetween( - toDisplayNumber(quote.amountIn, tokenIn.decimals) * priceIn, + toDisplayNumber(quote.amountIn, origin.decimals) * priceIn, toDisplayNumber(quote.amountOut, destination.decimals) * priceOut ) if (!Number.isFinite(quoted) || !Number.isFinite(listed)) { @@ -199,7 +212,12 @@ function isPositive(amount: string | undefined): amount is string { return amount !== undefined && BigInt(amount) > BigInt(0) } -function costNote(envelope: QuoteEnvelope, loss: number, symbol: string, decimals: number): string { +export function costNote( + envelope: QuoteEnvelope, + loss: number, + symbol: string, + decimals: number +): string { const bps = feeBps(envelope) const fee = bps === undefined @@ -213,6 +231,14 @@ function costNote(envelope: QuoteEnvelope, loss: number, symbol: string, decimal return `NEAR Intents runs on mainnet only, so this moves real funds. ${fee}${registration} After all costs you get about ${percent}% less in value than you pay, and at least ${formatUnits(envelope.quote.minAmountOut, decimals)} ${symbol}.` } +export function depositNote(envelope: QuoteEnvelope, tokenIn: ChainToken, account: string): string { + const refundFee = envelope.quote.refundFee + const refundNote = isPositive(refundFee) + ? `, minus a fee of ${formatUnits(refundFee, tokenIn.decimals)} ${tokenIn.symbol}` + : '' + return `Your funds go to a one-time deposit address from 1Click, which this app can't check on-chain. Sign within 5 minutes, because a late deposit may be refunded instead of swapped. If the swap can't finish, 1Click returns your funds to ${account}${refundNote}.` +} + interface DepositFlow { venueId: string venueName: string @@ -221,23 +247,27 @@ interface DepositFlow { extraNotes: string[] } +function assertNearAccount(account: string): void { + if (!NEAR_ACCOUNT.test(account)) { + throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.') + } +} + /** * The preview half of every 1Click flow: a dry quote, checked against what was asked and * against live prices. A dry quote makes no deposit address, so the race can run it for - * every venue, and Review runs it again before the real quote. + * every venue, and Review runs it again before the real quote. The caller has already + * checked the account belongs to the origin chain. */ -async function previewOneClick( +export async function previewOneClick( amountText: string, - tokenIn: ChainToken, + origin: QuoteOrigin, destination: Destination, account: string ) { - if (!NEAR_ACCOUNT.test(account)) { - throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.') - } - const amount = toBaseUnits(amountText, tokenIn.decimals) + const amount = toBaseUnits(amountText, origin.decimals) const shared = { - originAsset: assetIdOf(tokenIn), + originAsset: origin.assetId, destinationAsset: destination.assetId, amount, account, @@ -248,18 +278,36 @@ async function previewOneClick( const dryDeadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString() const preview = await requestQuote({ ...shared, dry: true, deadline: dryDeadline }) assertQuoteMatches(preview, { ...shared, dry: true, deadline: dryDeadline }) - await assertFairValue(preview, tokenIn, destination) + await assertFairValue(preview, origin, destination) return { shared, preview } } -/** What a dry quote says the swap would give back. Never reads a deposit address. */ -export async function quoteOneClick( - amountText: string, - tokenIn: ChainToken, - destination: Destination, - account: string -): Promise { - const { preview } = await previewOneClick(amountText, tokenIn, destination, account) +/** + * The real quote, which creates a deposit address, under the same checks as the preview. + * The address itself is left to the caller, because its shape depends on the chain. + */ +export async function liveOneClick( + shared: Awaited>['shared'], + origin: QuoteOrigin, + destination: Destination +) { + const deadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString() + const live = await requestQuote({ ...shared, dry: false, deadline }) + assertQuoteMatches(live, { ...shared, dry: false, deadline }) + const loss = await assertFairValue(live, origin, destination) + return { live, loss, deadline } +} + +export function assertNoDepositMemo(envelope: QuoteEnvelope): void { + const { depositMemo } = envelope.quote + if (depositMemo !== undefined && depositMemo !== '') { + throw new ChainActionRefused( + 'NEAR Intents asked for a deposit memo, which this app does not send. Nothing was signed or sent.' + ) + } +} + +export function venueQuoteFrom(preview: QuoteEnvelope, destination: Destination): VenueQuote { return { receive: { amount: formatUnits(preview.quote.amountOut, destination.decimals), @@ -269,6 +317,18 @@ export async function quoteOneClick( } } +/** What a dry quote says the swap would give back. Never reads a deposit address. */ +export async function quoteOneClick( + amountText: string, + tokenIn: ChainToken, + destination: Destination, + account: string +): Promise { + assertNearAccount(account) + const { preview } = await previewOneClick(amountText, nearOrigin(tokenIn), destination, account) + return venueQuoteFrom(preview, destination) +} + /** The quote flow behind every 1Click venue. Only the destination and the wording differ. */ export async function prepareOneClickDeposit( flow: DepositFlow, @@ -277,31 +337,22 @@ export async function prepareOneClickDeposit( destination: Destination, account: string ): Promise { - const { shared } = await previewOneClick(amountText, tokenIn, destination, account) + assertNearAccount(account) + const origin = nearOrigin(tokenIn) + const { shared } = await previewOneClick(amountText, origin, destination, account) const amount = shared.amount - const deadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString() - const live = await requestQuote({ ...shared, dry: false, deadline }) - assertQuoteMatches(live, { ...shared, dry: false, deadline }) - const loss = await assertFairValue(live, tokenIn, destination) + const { live, loss, deadline } = await liveOneClick(shared, origin, destination) - const { depositAddress, depositMemo } = live.quote + const { depositAddress } = live.quote if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) { throw new ChainActionRefused( "NEAR Intents' deposit address didn't look right, so it was not used. Nothing was signed or sent." ) } - if (depositMemo !== undefined && depositMemo !== '') { - throw new ChainActionRefused( - 'NEAR Intents asked for a deposit memo, which this app does not send. Nothing was signed or sent.' - ) - } + assertNoDepositMemo(live) const transaction = await buildDepositTransaction(tokenIn, account, depositAddress, amount) - const refundFee = live.quote.refundFee - const refundNote = isPositive(refundFee) - ? `, minus a fee of ${formatUnits(refundFee, tokenIn.decimals)} ${tokenIn.symbol}` - : '' return { venueId: flow.venueId, @@ -316,7 +367,7 @@ export async function prepareOneClickDeposit( }, notes: [ costNote(live, loss, destination.symbol, destination.decimals), - `Your funds go to a one-time deposit address from 1Click, which this app can't check on-chain. Sign within 5 minutes, because a late deposit may be refunded instead of swapped. If the swap can't finish, 1Click returns your funds to ${account}${refundNote}.`, + depositNote(live, tokenIn, account), ...flow.extraNotes, ], signing: { family: 'near', transactions: [transaction] }, @@ -363,9 +414,7 @@ async function prepareSwap(request: ChainActionRequest, account: string): Promis : toNear ? `Swap ${amount} ${token.symbol} for NEAR through NEAR Intents, delivered to your NEAR account as NEAR or wNEAR.` : `Swap ${amount} ${token.symbol} for ${tokenOut.symbol} through NEAR Intents, into your NEAR account.`, - extraNotes: [ - 'NEAR Intents had an exploit of about $3.8M around 1 October 2026 in its bridge links. Funds were reported returned, but bridges and solvers it relies on remain a risk.', - ], + extraNotes: [ONECLICK_RISK_NOTE], }, request.amount, tokenIn, diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts new file mode 100644 index 00000000..fef9ab8f --- /dev/null +++ b/apps/dapp/lib/chains/oneclick-origin.ts @@ -0,0 +1,528 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +import { TransactionDataBuilder } from '@mysten/sui/transactions' +import { fromBase64, normalizeStructTag } from '@mysten/sui/utils' + +import { solanaDeposit } from '../weave/deposit/solana' +import { suiDeposit } from '../weave/deposit/sui' +import type { DepositChain, DepositExpectation } from '../weave/types' +import { depositStatus, listTokens, notifyDeposit } from './near/oneclick' +import { + assertNoDepositMemo, + costNote, + depositNote, + EXPLORER_NOTE, + liveOneClick, + ONECLICK_RISK_NOTE, + previewOneClick, + venueQuoteFrom, + type Destination, + type QuoteOrigin, +} from './near/swap' +import { formatUnits } from './near/tokens' +import { explorerUrl as solanaExplorerUrl, feePayerSignature } from './solana/submit' +import { SOLANA_TOKENS } from './solana/tokens' +import { SUI_TOKENS } from './sui/tokens' +import { + ChainActionRefused, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SignedPayload, + type SigningPayload, + type SubmitInput, + type SubmitResult, + type VenueQuote, +} from './types' + +/** + * NEAR Intents 1Click as a swap route on a chain other than NEAR. The wallet signs one + * transfer to a one-time deposit address, and 1Click pays the swap out on the same chain. + * + * The browser carries `context` between prepare and submit, so nothing in it is believed + * until `readIssued` proves this server wrote it. See `proofOf`. + */ + +export type OneClickOriginChain = 'solana' | 'sui' | 'avalanche' | 'monad' + +const VENUE_NAME = 'NEAR Intents 1Click' + +/** What 1Click calls each chain in its token list. */ +const BLOCKCHAIN: Record = { + solana: 'sol', + sui: 'sui', + avalanche: 'avax', + monad: 'monad', +} + +/** Only these are spent through 1Click in this app so far. Native coins go through the chain's own swap. */ +const SPENDABLE = new Set(['USDC', 'USDT']) + +/** + * The limits inside the submit route's 60 seconds. The status wait counts from the start and + * includes the confirm wait. One last status request and a pause on top keep it under 60. + */ +const CONFIRM_WAIT_MS = 40_000 +const STATUS_WAIT_MS = 50_000 +const STATUS_POLL_MS = 2_000 + +interface Origin { + chainName: string + family: 'solana' | 'sui' + tokens: readonly ChainToken[] + /** The token id that stands for the native coin, which 1Click may list with no contract. */ + nativeId: string + ownSwap: string + deposit: DepositChain + /** The chain's identifier for signed bytes, known before the network has answered. */ + hashOf: (signed: SignedPayload) => string | undefined + explorerUrl: (hash: string) => string +} + +function suiExplorerUrl(hash: string): string { + return `https://suiscan.xyz/mainnet/tx/${hash}` +} + +const ORIGINS: Partial> = { + solana: { + chainName: 'Solana', + family: 'solana', + tokens: SOLANA_TOKENS, + nativeId: 'So11111111111111111111111111111111111111112', + ownSwap: 'Jupiter', + deposit: solanaDeposit, + hashOf: (signed) => + signed.family === 'solana' ? feePayerSignature(signed.signedTransaction) : undefined, + explorerUrl: solanaExplorerUrl, + }, + sui: { + chainName: 'Sui', + family: 'sui', + tokens: SUI_TOKENS, + nativeId: '0x2::sui::SUI', + ownSwap: 'Cetus', + deposit: suiDeposit, + hashOf: (signed) => + signed.family === 'sui' + ? TransactionDataBuilder.getDigestFromBytes(fromBase64(signed.transaction)) + : undefined, + explorerUrl: suiExplorerUrl, + }, +} + +/** The form two copies of a contract compare in. Case is part of a base58 mint, so it stays. */ +function normalizeContract(chain: OneClickOriginChain, contract: string): string { + if (chain === 'solana') return contract + if (chain !== 'sui') return contract.toLowerCase() + try { + return normalizeStructTag(contract) + } catch { + return contract + } +} + +/** + * The 1Click asset for a token, matched on chain and contract. The list repeats symbols, and + * anyone can name a token USDC, so a symbol alone never matches. Two entries for one contract + * leave no way to say which is real, so that is no match either. + * + * A native coin has no contract on its chain, and the list may carry it with none. It matches + * only as the chain's own native token id against the one entry with that symbol and no contract. + */ +export async function oneClickAssetId( + chain: OneClickOriginChain, + token: ChainToken +): Promise { + const entries = (await listTokens()).filter( + (entry) => entry.blockchain === BLOCKCHAIN[chain] && entry.decimals === token.decimals + ) + const wanted = normalizeContract(chain, token.id) + const matching = entries.filter( + (entry) => + typeof entry.contractAddress === 'string' && + entry.contractAddress !== '' && + normalizeContract(chain, entry.contractAddress) === wanted + ) + if (matching.length === 1) return matching[0]?.assetId + if (matching.length > 1 || token.id !== ORIGINS[chain]?.nativeId) return undefined + const native = entries.filter( + (entry) => + (entry.contractAddress === undefined || + entry.contractAddress === null || + entry.contractAddress === '') && + entry.symbol.toUpperCase() === token.symbol.toUpperCase() + ) + return native.length === 1 ? native[0]?.assetId : undefined +} + +function secret(): string | undefined { + const value = process.env['AUTH_SECRET'] + return value !== undefined && value.length >= 32 ? value : undefined +} + +/** Without the secret nothing could be issued safely at Review, so no route is offered at all. */ +function signingKey(): string { + const key = secret() + if (key === undefined) { + throw new ChainActionRefused(`${VENUE_NAME} isn't available here right now.`) + } + return key +} + +interface Issued { + depositAddress: string + /** The token's id. */ + token: string + /** Base units. */ + amount: string + deadline: string +} + +/** + * Proves a deposit came from this server. The browser hands `context` back at submit, and + * could change it along with the bytes it wants sent, so every field that decides where funds + * go is signed here, for this venue and this account. The prefix keeps this signature from + * ever passing as a session's, which shares the secret. + */ +function proofOf(venueId: string, account: string, issued: Issued, key: string): string { + const message = JSON.stringify([ + venueId, + account, + issued.token, + issued.amount, + issued.depositAddress, + issued.deadline, + ]) + return createHmac('sha256', key).update(`oneclick-origin:${message}`).digest('base64url') +} + +function sameProof(given: string, expected: string): boolean { + const a = Buffer.from(given) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + +function notIssued(): never { + throw new ChainActionRefused( + "This swap's details are not what this app issued, so nothing was sent. Get a fresh quote and sign that." + ) +} + +/** The deposit this server issued, or a refusal. Never returns anything it did not sign. */ +function readIssued( + origin: Origin, + venueId: string, + account: string, + context: Record | undefined +): DepositExpectation { + const key = secret() + const depositAddress = context?.['depositAddress'] + const tokenId = context?.['token'] + const amount = context?.['amount'] + const deadline = context?.['deadline'] + const proof = context?.['proof'] + if ( + key === undefined || + depositAddress === undefined || + tokenId === undefined || + amount === undefined || + deadline === undefined || + proof === undefined || + !sameProof( + proof, + proofOf(venueId, account, { depositAddress, token: tokenId, amount, deadline }, key) + ) + ) { + return notIssued() + } + const token = origin.tokens.find((t) => t.id === tokenId) + if (token === undefined) return notIssued() + if (!(Date.parse(deadline) > Date.now())) { + throw new ChainActionRefused( + 'This quote expired before it was sent, so nothing was sent. Get a fresh quote and sign right away.' + ) + } + return { + account, + depositAddress, + memo: undefined, + amount: formatUnits(amount, token.decimals, token.decimals), + token, + } +} + +function originOf(chain: OneClickOriginChain): Origin { + const origin = ORIGINS[chain] + if (origin === undefined) { + throw new ChainActionRefused(`${VENUE_NAME} can't swap on this chain yet.`) + } + return origin +} + +interface Route { + tokenIn: ChainToken + tokenOut: ChainToken + origin: QuoteOrigin + destination: Destination +} + +/** What the swap would touch, or the sentence for why not. A refusal drops the route from the race. */ +async function routeFor( + origin: Origin, + chain: OneClickOriginChain, + request: ChainActionRequest, + account: string +): Promise { + if (request.kind !== 'swap') { + throw new ChainActionRefused(`${VENUE_NAME} swaps tokens. It can't ${request.kind} here.`) + } + if (!origin.deposit.isAccount(account)) { + throw new ChainActionRefused(`That is not a ${origin.chainName} wallet address.`) + } + const tokenIn = tokenBySymbol(origin.tokens, request.tokenIn) + const tokenOut = tokenBySymbol(origin.tokens, request.tokenOut ?? '') + if (tokenIn === undefined || tokenOut === undefined) { + throw new ChainActionRefused(`I can only swap tokens I have confirmed on ${origin.chainName}.`) + } + if (tokenIn.id === tokenOut.id) throw new ChainActionRefused('Those are the same token.') + if (!SPENDABLE.has(tokenIn.symbol)) { + throw new ChainActionRefused( + `${VENUE_NAME} here spends USDC or USDT, not ${tokenIn.symbol}. ${origin.ownSwap} can swap ${tokenIn.symbol}.` + ) + } + + let ids: [string | undefined, string | undefined] + try { + ids = await Promise.all([oneClickAssetId(chain, tokenIn), oneClickAssetId(chain, tokenOut)]) + } catch { + throw new ChainActionRefused( + "NEAR Intents isn't answering right now. Try again in a minute. Nothing was signed or sent." + ) + } + const [originAsset, destinationAsset] = ids + if (originAsset === undefined || destinationAsset === undefined) { + const missing = originAsset === undefined ? tokenIn.symbol : tokenOut.symbol + throw new ChainActionRefused( + `${VENUE_NAME} doesn't list ${missing} on ${origin.chainName}. Nothing was signed or sent.` + ) + } + return { + tokenIn, + tokenOut, + origin: { assetId: originAsset, decimals: tokenIn.decimals }, + destination: { + assetId: destinationAsset, + symbol: tokenOut.symbol, + decimals: tokenOut.decimals, + recipientType: 'DESTINATION_CHAIN', + }, + } +} + +function stillProcessing(hash: string, depositAddress: string): SubmitResult { + return { + ok: false, + pending: true, + error: `Your deposit was sent (${hash}) and the swap is still processing, which can take up to 15 minutes. Don't send it again. ${EXPLORER_NOTE} ${depositAddress}.`, + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Sends through the chain's own deposit submit, which checks the bytes again first. Its + * refusals only ever come before the send, so they prove nothing moved. Anything else thrown, + * or no answer inside the limit, leaves open that the transfer went out. + */ +async function send( + origin: Origin, + signed: SignedPayload, + expectation: DepositExpectation +): Promise { + const unconfirmed = (): SubmitResult => { + const hash = origin.hashOf(signed) + const where = hash === undefined ? "your wallet's activity" : origin.explorerUrl(hash) + return { + ok: false, + pending: true, + error: `This may have been sent and isn't confirmed yet, so don't send it again. Check ${where} first.`, + } + } + let timer: ReturnType | undefined + const limit = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), CONFIRM_WAIT_MS) + }) + try { + return ( + (await Promise.race([origin.deposit.submit(signed, expectation), limit])) ?? unconfirmed() + ) + } catch (e) { + return e instanceof ChainActionRefused ? { ok: false, error: e.message } : unconfirmed() + } finally { + clearTimeout(timer) + } +} + +/** The venue behind a 1Click swap from `chain`. Refuses plainly on a chain with no deposit built. */ +export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { + const venueId = `1click-${chain}` + + async function quote(request: ChainActionRequest, account: string): Promise { + const origin = originOf(chain) + const route = await routeFor(origin, chain, request, account) + signingKey() + const { preview } = await previewOneClick( + request.amount, + route.origin, + route.destination, + account + ) + return venueQuoteFrom(preview, route.destination) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const origin = originOf(chain) + const { + tokenIn, + tokenOut, + origin: spending, + destination, + } = await routeFor(origin, chain, request, account) + const key = signingKey() + const { shared } = await previewOneClick(request.amount, spending, destination, account) + const { live, loss, deadline } = await liveOneClick(shared, spending, destination) + + const { depositAddress } = live.quote + if (depositAddress === undefined || !origin.deposit.isDepositAddress(depositAddress)) { + throw new ChainActionRefused( + "NEAR Intents' deposit address didn't look right, so it was not used. Nothing was signed or sent." + ) + } + assertNoDepositMemo(live) + + const signing = await origin.deposit.build({ + account, + depositAddress, + memo: undefined, + amount: request.amount, + token: tokenIn, + }) + + const issued: Issued = { depositAddress, token: tokenIn.id, amount: shared.amount, deadline } + return { + venueId, + venueName: VENUE_NAME, + kind: 'swap', + summary: `Swap ${request.amount} ${tokenIn.symbol} for ${tokenOut.symbol} through NEAR Intents, into your ${origin.chainName} account.`, + pay: { amount: request.amount, symbol: tokenIn.symbol }, + receive: { + amount: formatUnits(live.quote.amountOut, destination.decimals), + symbol: destination.symbol, + approximate: true, + }, + notes: [ + costNote(live, loss, destination.symbol, destination.decimals), + depositNote(live, tokenIn, account), + ONECLICK_RISK_NOTE, + ], + signing, + context: { ...issued, proof: proofOf(venueId, account, issued, key) }, + } + } + + function assertSafe( + payload: SigningPayload, + account: string, + context?: Record + ): void { + const origin = originOf(chain) + origin.deposit.assertSafe(payload, readIssued(origin, venueId, account, context)) + } + + /** + * Every `{ ok: false }` without `pending` here comes before the transfer was sent, or from + * the chain's own submit having proved it did not move. Once the network has the transfer, + * a swap that fails at 1Click still has funds in flight or on their way back, so that is + * always pending and the latch stays closed. + */ + async function submit(input: SubmitInput): Promise { + const { account, signed, context } = input + const origin = originOf(chain) + if (signed.family !== origin.family) { + return { + ok: false, + error: `That is not a ${origin.chainName} transaction, so nothing was sent.`, + } + } + let expectation: DepositExpectation + try { + expectation = readIssued(origin, venueId, account, context) + } catch (e) { + if (e instanceof ChainActionRefused) return { ok: false, error: e.message } + throw e + } + const { depositAddress } = expectation + + const startedAt = Date.now() + const sent = await send(origin, signed, expectation) + if (!sent.ok) { + return sent.pending === true + ? { ...sent, error: `${sent.error} ${EXPLORER_NOTE} ${depositAddress}.` } + : sent + } + + const { hash } = sent + await notifyDeposit(depositAddress, hash) + + while (Date.now() - startedAt < STATUS_WAIT_MS) { + const body = await depositStatus(depositAddress) + const recipient = body?.quoteResponse?.quoteRequest?.recipient + if (recipient !== undefined && recipient !== account) { + return { + ok: false, + pending: true, + error: `That deposit address belongs to a different swap. Your transfer is ${hash}. Check your wallet's activity before trying again.`, + } + } + switch (body?.status) { + case 'SUCCESS': + return sent + case 'REFUNDED': + return { + ok: false, + pending: true, + error: `The swap did not go through, and NEAR Intents says your funds were refunded to ${account}. Check your balance before trying again. ${EXPLORER_NOTE} ${depositAddress}.`, + } + case 'FAILED': + return { + ok: false, + pending: true, + error: `The swap failed after your deposit (${hash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`, + } + case 'INCOMPLETE_DEPOSIT': + return { + ok: false, + pending: true, + error: `NEAR Intents received less than the quote needed, so it won't swap. What arrived is due back at ${account}. ${EXPLORER_NOTE} ${depositAddress}.`, + } + default: + await delay(STATUS_POLL_MS) + } + } + return stillProcessing(hash, depositAddress) + } + + return { + id: venueId, + name: VENUE_NAME, + kinds: ['swap'], + quote, + prepare, + assertSafe, + submit, + } +} diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index 406f4514..f96660e2 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -115,6 +115,6 @@ export async function prepareChainAction( const venue = pickVenue(chainApi, request) const prepared = await venue.prepare(request, account) - venue.assertSafe(prepared.signing, account) + venue.assertSafe(prepared.signing, account, prepared.context) return prepared } diff --git a/apps/dapp/lib/chains/solana/jupiter-lend.ts b/apps/dapp/lib/chains/solana/jupiter-lend.ts index 231afc39..c0e96428 100644 --- a/apps/dapp/lib/chains/solana/jupiter-lend.ts +++ b/apps/dapp/lib/chains/solana/jupiter-lend.ts @@ -10,11 +10,14 @@ import { z } from 'zod' import { base64FromBytes, bytesFromBase64 } from '../base64' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, + type VenueQuote, } from '../types' import { assertDepositAmount, @@ -152,7 +155,10 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV : { basePercent, rewardPercent } } - async function prepare(request: ChainActionRequest, account: string): Promise { + function checkedDeposit( + request: ChainActionRequest, + account: string + ): { token: ChainToken; amount: string } { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) if (request.kind !== 'supply' || token === undefined || !SUPPORTED.has(token.symbol)) { @@ -160,7 +166,16 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV `Jupiter Lend here takes USDC or USDT deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Jupiter Lend".` ) } - const amount = toBaseUnits(request.amount, token.decimals) + return { token, amount: toBaseUnits(request.amount, token.decimals) } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { token } = checkedDeposit(request, account) + return quotedApy(await readApy(token.id)) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount } = checkedDeposit(request, account) const apy = await readApy(token.id) const { status, body } = await venueFetch(`${JUPITER_API}/lend/v1/earn/deposit`, { @@ -197,6 +212,7 @@ export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainV id: 'jupiter-lend', name: 'Jupiter Lend Earn', kinds: ['supply'], + quote, prepare, assertSafe: solanaAssertSafe(JUPITER_LEND_POLICY), } diff --git a/apps/dapp/lib/chains/solana/jupiter-swap.ts b/apps/dapp/lib/chains/solana/jupiter-swap.ts index 87f2270d..f615c3a0 100644 --- a/apps/dapp/lib/chains/solana/jupiter-swap.ts +++ b/apps/dapp/lib/chains/solana/jupiter-swap.ts @@ -11,6 +11,7 @@ import { type PreparedAction, type SubmitInput, type SubmitResult, + type VenueQuote, } from '../types' import { assertWrapAtMost, solanaAssertSafe } from './assert-safe' import { JUPITER_API, jupiterHeaders, venueFetch } from './http' @@ -126,7 +127,11 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue const env = options.env ?? process.env const assertSafe = solanaAssertSafe(JUPITER_SWAP_POLICY) - async function prepare(request: ChainActionRequest, account: string): Promise { + /** + * The order call and every check on what comes back. The race and Review both go through + * it, so a quote the agents can pick is one Review would build. + */ + async function checkedOrder(request: ChainActionRequest, account: string) { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') if (request.kind !== 'swap') { throw new ChainActionRefused(`Jupiter swaps tokens. It can't ${request.kind} here.`) @@ -225,6 +230,25 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue if (tokenIn.id === WRAPPED_SOL) assertWrapAtMost(order.transaction, BigInt(amount)) + const { transaction, requestId } = order + return { order, transaction, requestId, tokenIn, tokenOut, minimum, referral } + } + + /** What the swap would give back. The transaction is left here, since nothing signs it. */ + async function quote(request: ChainActionRequest, account: string): Promise { + const { order, tokenOut } = await checkedOrder(request, account) + return { + receive: { + amount: fromBaseUnits(order.outAmount, tokenOut.decimals), + symbol: tokenOut.symbol, + }, + } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { order, transaction, requestId, tokenIn, tokenOut, minimum, referral } = + await checkedOrder(request, account) + const received = fromBaseUnits(order.outAmount, tokenOut.decimals) const notes = [ `The price can move before this lands. You get at least ${minimum} ${tokenOut.symbol} or the swap fails, and network fees still apply.`, @@ -249,8 +273,8 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue pay: { amount: request.amount, symbol: tokenIn.symbol }, receive: { amount: received, symbol: tokenOut.symbol, approximate: true }, notes, - signing: { family: 'solana', transaction: order.transaction }, - context: { requestId: order.requestId }, + signing: { family: 'solana', transaction }, + context: { requestId }, } } @@ -304,6 +328,7 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue id: 'jupiter', name: 'Jupiter', kinds: ['swap'], + quote, prepare, assertSafe, submit, diff --git a/apps/dapp/lib/chains/solana/kamino.ts b/apps/dapp/lib/chains/solana/kamino.ts index 825ff531..140797e1 100644 --- a/apps/dapp/lib/chains/solana/kamino.ts +++ b/apps/dapp/lib/chains/solana/kamino.ts @@ -3,11 +3,14 @@ import { z } from 'zod' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, + type VenueQuote, } from '../types' import { assertDepositAmount, solanaAssertSafe } from './assert-safe' import { venueFetch } from './http' @@ -120,7 +123,11 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { return rewardPercent === 0 ? { basePercent } : { basePercent, rewardPercent } } - async function prepare(request: ChainActionRequest, account: string): Promise { + /** The vault a deposit would use, after every check that needs no transaction. */ + async function checkedDeposit( + request: ChainActionRequest, + account: string + ): Promise<{ token: ChainToken; amount: string; vault: Vault }> { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) if (request.kind !== 'supply' || token === undefined || token.symbol !== USDC) { @@ -142,7 +149,16 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { `This vault takes at least ${fromBaseUnits(minimum, token.decimals)} USDC. Try a larger amount.` ) } + return { token, amount, vault } + } + async function quote(request: ChainActionRequest, account: string): Promise { + const { vault } = await checkedDeposit(request, account) + return quotedApy(await readApy(vault.address)) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount, vault } = await checkedDeposit(request, account) const apy = await readApy(vault.address) const { status, body } = await venueFetch(`${KAMINO_API}/ktx/kvault/deposit`, { label: 'Kamino', @@ -178,6 +194,7 @@ export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue { id: 'kamino', name: 'Kamino Earn', kinds: ['supply'], + quote, prepare, assertSafe: solanaAssertSafe(KAMINO_POLICY), } diff --git a/apps/dapp/lib/chains/solana/lulo.ts b/apps/dapp/lib/chains/solana/lulo.ts index e15c42d0..9198ea62 100644 --- a/apps/dapp/lib/chains/solana/lulo.ts +++ b/apps/dapp/lib/chains/solana/lulo.ts @@ -3,11 +3,14 @@ import { z } from 'zod' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, + type ChainToken, type ChainVenue, type PreparedAction, + type VenueQuote, } from '../types' import { assertDepositAmount, solanaAssertSafe } from './assert-safe' import { venueFetch } from './http' @@ -48,7 +51,10 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { : undefined } - async function prepare(request: ChainActionRequest, account: string): Promise { + function checkedDeposit( + request: ChainActionRequest, + account: string + ): { token: ChainToken; amount: string } { if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) if (request.kind !== 'supply' || token === undefined || token.symbol !== 'USDC') { @@ -56,7 +62,16 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { `Lulo here takes USDC deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Lulo".` ) } - const amount = toBaseUnits(request.amount, token.decimals) + return { token, amount: toBaseUnits(request.amount, token.decimals) } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + checkedDeposit(request, account) + return quotedApy(await readApy()) + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount } = checkedDeposit(request, account) const apy = await readApy() const { status, body } = await venueFetch(`${LULO_API}/v1/generate.transactions.deposit`, { @@ -102,6 +117,7 @@ export function createLuloVenue(options: LuloOptions): ChainVenue { id: 'lulo', name: 'Lulo Protected', kinds: ['supply'], + quote, prepare, assertSafe: solanaAssertSafe(LULO_POLICY), } diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts index 5b87810b..cda015cc 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -1,3 +1,4 @@ +import { oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule, ChainVenue } from '../types' import { createJupiterLendVenue } from './jupiter-lend' import { createJupiterSwapVenue } from './jupiter-swap' @@ -15,6 +16,7 @@ export function buildSolanaVenues( const luloKey = env['LULO_API_KEY']?.trim() return [ createJupiterSwapVenue({ priceUsd, env }), + oneClickOriginVenue('solana'), createJupiterLendVenue({ env }), createKaminoVenue(), ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []), diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts index 75d5ce00..4f87c9be 100644 --- a/apps/dapp/lib/chains/solana/venues.ts +++ b/apps/dapp/lib/chains/solana/venues.ts @@ -18,6 +18,18 @@ export const solanaVenues: Venue[] = [ capability: 'Swaps USDC, USDT, SOL, JitoSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.', }, + { + id: '1click-solana', + family: 'solana', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Solana'], + bestFor: 'A second swap price from competing market makers, spending USDC or USDT', + url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', + integration: 'executes', + capability: + 'Swaps USDC or USDT for another token in your Solana wallet that 1Click also lists. Your wallet signs one transfer to a one-time deposit address. Mainnet only. No swap has been signed through this app yet.', + }, { id: 'jupiter-lend', family: 'solana', diff --git a/apps/dapp/lib/chains/sui/cetus.ts b/apps/dapp/lib/chains/sui/cetus.ts index f83bc925..07d7a498 100644 --- a/apps/dapp/lib/chains/sui/cetus.ts +++ b/apps/dapp/lib/chains/sui/cetus.ts @@ -9,6 +9,7 @@ import { type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { cetusOverlay, judgeRoute, SLIPPAGE_BPS } from './cetus-rules' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' @@ -35,7 +36,8 @@ function notes(tokens: string[], slippagePercent: number, feeBps: number | undef ] } -async function prepare(request: ChainActionRequest, account: string): Promise { +/** The best route and every check on it. The race and Review both go through this. */ +async function bestRoute(request: ChainActionRequest, account: string) { assertSuiAccount(account) const tokenIn = tokenBySymbol(SUI_TOKENS, request.tokenIn) const tokenOut = tokenBySymbol(SUI_TOKENS, request.tokenOut ?? '') @@ -87,6 +89,20 @@ async function prepare(request: ChainActionRequest, account: string): Promise { + const { tokenOut, amountOut } = await bestRoute(request, account) + return { + receive: { amount: displayAmount(amountOut, tokenOut.decimals), symbol: tokenOut.symbol }, + } +} + +async function prepare(request: ChainActionRequest, account: string): Promise { + const { tokenIn, tokenOut, client, overlay, aggregator, slippage, route, amountOut } = + await bestRoute(request, account) const tx = new Transaction() tx.setSender(account) @@ -114,6 +130,7 @@ export const cetusVenue: ChainVenue = { id: 'cetus', name: VENUE_NAME, kinds: ['swap'], + quote, prepare, assertSafe: (payload: SigningPayload, account: string) => assertPtbSafe(payload, account, CETUS_POLICY), diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts index 2595e9ca..01d88086 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,3 +1,4 @@ +import { oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule } from '../types' import { cetusVenue } from './cetus' import { naviVenue } from './navi' @@ -9,7 +10,7 @@ import { SUI_TOKENS } from './tokens' export const suiModule: ChainModule = { chain: 'sui', tokens: SUI_TOKENS, - venues: [cetusVenue, naviVenue, springsuiVenue], + venues: [cetusVenue, oneClickOriginVenue('sui'), naviVenue, springsuiVenue], defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui' }, priceUsd: (token) => priceFromLlama(token), submit: (input) => submitSui(input), diff --git a/apps/dapp/lib/chains/sui/navi.ts b/apps/dapp/lib/chains/sui/navi.ts index 916c79fa..868ffcb4 100644 --- a/apps/dapp/lib/chains/sui/navi.ts +++ b/apps/dapp/lib/chains/sui/navi.ts @@ -4,12 +4,14 @@ import { depositCoinPTB, getPools } from '@naviprotocol/lending' import { ChainActionRefused, + quotedApy, tokenBySymbol, toBaseUnits, type ChainActionRequest, type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { naviApy, poolNotes } from './navi-rules' @@ -23,7 +25,8 @@ const POOL_CACHE_MS = 30_000 /** NAVI reports pool amounts with nine decimals, whatever the token's own. */ const POOL_AMOUNT_SCALE = 1e9 -async function prepare(request: ChainActionRequest, account: string): Promise { +/** The pool a deposit would use, after every check that needs no transaction. */ +async function checkedPool(request: ChainActionRequest, account: string) { assertSuiAccount(account) const token = tokenBySymbol(SUI_TOKENS, request.tokenIn) if (token?.symbol !== 'USDC') { @@ -49,6 +52,16 @@ async function prepare(request: ChainActionRequest, account: string): Promise { + const { pool } = await checkedPool(request, account) + return quotedApy(naviApy(pool)) +} + +async function prepare(request: ChainActionRequest, account: string): Promise { + const { token, amount, pool } = await checkedPool(request, account) const tx = new Transaction() tx.setSender(account) @@ -80,6 +93,7 @@ export const naviVenue: ChainVenue = { id: 'navi', name: 'NAVI', kinds: ['supply'], + quote, prepare, assertSafe: (payload: SigningPayload, account: string) => assertPtbSafe(payload, account, NAVI_POLICY), diff --git a/apps/dapp/lib/chains/sui/springsui.ts b/apps/dapp/lib/chains/sui/springsui.ts index 9c4bfce9..da172d22 100644 --- a/apps/dapp/lib/chains/sui/springsui.ts +++ b/apps/dapp/lib/chains/sui/springsui.ts @@ -8,6 +8,7 @@ import { type ChainVenue, type PreparedAction, type SigningPayload, + type VenueQuote, } from '../types' import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client' import { @@ -26,7 +27,8 @@ const SUI_DECIMALS = 9 const SSUI_DECIMALS = 9 const DRY_RUN_TIMEOUT_MS = 15_000 -async function prepare(request: ChainActionRequest, account: string): Promise { +/** Builds the stake and dry-runs it, which moves nothing, to read what it would mint. */ +async function checkedStake(request: ChainActionRequest, account: string) { assertSuiAccount(account) if (request.tokenIn.trim().toUpperCase() !== 'SUI') { throw new ChainActionRefused( @@ -69,7 +71,17 @@ async function prepare(request: ChainActionRequest, account: string): Promise { + const { receive } = await checkedStake(request, account) + return { receive: { amount: receive, symbol: 'sSUI' } } +} + +async function prepare(request: ChainActionRequest, account: string): Promise { + const { transaction, receive } = await checkedStake(request, account) return { venueId: 'springsui', venueName: 'SpringSui', @@ -90,6 +102,7 @@ export const springsuiVenue: ChainVenue = { id: 'springsui', name: 'SpringSui', kinds: ['stake'], + quote, prepare, assertSafe: (payload: SigningPayload, account: string) => assertPtbSafe(payload, account, SPRINGSUI_POLICY), diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts index 3d402ea5..2ea306eb 100644 --- a/apps/dapp/lib/chains/sui/venues.ts +++ b/apps/dapp/lib/chains/sui/venues.ts @@ -29,6 +29,18 @@ export const suiVenues: Venue[] = [ integration: 'executes', capability: 'Swaps SUI, USDC, USDsui and USDT', }, + { + id: '1click-sui', + family: 'sui', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Sui'], + url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', + bestFor: 'A second swap price from competing market makers, spending USDC', + integration: 'executes', + capability: + 'Swaps USDC for SUI in your Sui wallet, the pair 1Click lists here. Your wallet signs one transfer to a one-time deposit address. Mainnet only. No swap has been signed through this app yet.', + }, { id: 'navi', family: 'sui', diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 21ca5533..a5d0b308 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -119,8 +119,15 @@ export interface ChainVenue { * unexpected program, a transfer out. Runs on what the venue's API returned * before the user sees it, and again on signed bytes at submit, because the * venue's API and the browser are both outside what this server controls. + * + * `context` is what `prepare` issued, handed back by the browser. A venue that reads it + * must prove the server wrote it before believing any of it. */ - assertSafe: (payload: SigningPayload, account: string) => void + assertSafe: ( + payload: SigningPayload, + account: string, + context?: Record | undefined + ) => void /** Overrides the module's submit where a venue lands its own transactions. */ submit?: ((input: SubmitInput) => Promise) | undefined } @@ -136,6 +143,14 @@ export interface ChainModule { submit: (input: SubmitInput) => Promise } +/** + * A venue's rate as the race reads it: base and reward together, since the agents compare + * one figure. No rate read means no rate here, never a zero or a guess. + */ +export function quotedApy(apy: PreparedAction['apy']): VenueQuote { + return apy === undefined ? {} : { apyPercent: apy.basePercent + (apy.rewardPercent ?? 0) } +} + export function tokenBySymbol( tokens: readonly ChainToken[], symbol: string diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index 529dbb2a..20bbd57d 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -16,7 +16,7 @@ import { chainCapabilities } from '../chains/capabilities' * once its venues can be quoted, so the sheet never promises a race that does * not run. */ -const AGENT_RACE_CHAINS = ['stellar', 'near'] +const AGENT_RACE_CHAINS = ['stellar', 'near', 'solana', 'sui'] const SWAP_RACED = 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.' diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index b22ad284..0ddf5480 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -64,8 +64,13 @@ const COMPUTE_UNIT_LIMIT = 100_000 /** About 0.00001 SOL at the limit above, enough to land without a live fee estimate. */ const COMPUTE_UNIT_PRICE = BigInt(100_000) -const NOT_ONE_TRANSFER = - 'This transaction asks the Token Program for something other than one USDC transfer.' +function tokenOf(expectation: DepositExpectation): ChainToken { + return expectation.token ?? USDC +} + +function notOneTransfer(token: ChainToken): string { + return `This transaction asks the Token Program for something other than one ${token.symbol} transfer.` +} const RpcAccount = z.object({ result: z.object({ value: z.object({ owner: z.string() }).nullable() }), @@ -91,13 +96,14 @@ function orderUnits(expectation: DepositExpectation): bigint { if (!isAddress(account)) refuse('That is not a Solana wallet address.') if (!isAddress(depositAddress)) refuse('This order’s deposit address is not a Solana address.') if (depositAddress === account) refuse('This order’s deposit address is your own wallet.') + const token = tokenOf(expectation) let units: bigint try { - units = BigInt(toBaseUnits(amount, USDC.decimals)) + units = BigInt(toBaseUnits(amount, token.decimals)) } catch (e) { return refuse(e instanceof Error ? e.message : 'That is not an amount.') } - if (units > MAX_U64) refuse('This order’s amount is too large for USDC.') + if (units > MAX_U64) refuse(`This order’s amount is too large for ${token.symbol}.`) return units } @@ -152,7 +158,7 @@ function checkAccountCreation( expectation.account, destination, expectation.depositAddress, - USDC.id, + tokenOf(expectation).id, SYSTEM_PROGRAM, TOKEN_PROGRAM, ] @@ -174,13 +180,14 @@ function checkTransfer( destination: string, units: bigint ): void { + const token = tokenOf(expectation) const [from, mint, to, owner, ...rest] = accountsOf(transaction, instruction) - if (rest.length > 0 || owner === undefined) refuse(NOT_ONE_TRANSFER) - if (mint !== USDC.id || instruction.data[9] !== USDC.decimals) { - refuse('This transfer is not USDC.') + if (rest.length > 0 || owner === undefined) refuse(notOneTransfer(token)) + if (mint !== token.id || instruction.data[9] !== token.decimals) { + refuse(`This transfer is not ${token.symbol}.`) } - if (from !== associatedTokenAccount(expectation.account, USDC.id)) { - refuse('This transfer does not come from your USDC account.') + if (from !== associatedTokenAccount(expectation.account, token.id)) { + refuse(`This transfer does not come from your ${token.symbol} account.`) } if (owner !== expectation.account) refuse('This transfer is not authorised by your wallet.') if (to !== destination) refuse('This transfer does not go to this order’s deposit address.') @@ -191,12 +198,13 @@ function checkTransfer( /** * Only this shape passes: compute budget settings, then at most one creation of the - * deposit's USDC account, then one TransferChecked. Lookup tables are refused outright + * deposit's token account, then one TransferChecked. Lookup tables are refused outright * because resolving one needs the network, and this check has to see every account. */ function assertSafe(payload: SigningPayload, expectation: DepositExpectation): void { if (payload.family !== 'solana') refuse('That is not a Solana transaction.') const units = orderUnits(expectation) + const token = tokenOf(expectation) const transaction = decode(payload.transaction) if (hasLookupTables(payload.transaction)) { refuse('This transaction uses a lookup table, which hides accounts from this check.') @@ -218,19 +226,19 @@ function assertSafe(payload: SigningPayload, expectation: DepositExpectation): v return 'create' case TOKEN_PROGRAM: if (instruction.data.length !== 10 || instruction.data[0] !== TRANSFER_CHECKED) { - refuse(NOT_ONE_TRANSFER) + refuse(notOneTransfer(token)) } return 'transfer' default: - return refuse('This transaction calls a program a USDC deposit does not need.') + return refuse(`This transaction calls a program a ${token.symbol} deposit does not need.`) } }) if (!/^(budget,)*(create,)?transfer$/.test(steps.join(','))) { - refuse('This transaction is not the one USDC transfer this order needs.') + refuse(`This transaction is not the one ${token.symbol} transfer this order needs.`) } checkComputeBudget(budget) - const destination = associatedTokenAccount(expectation.depositAddress, USDC.id) + const destination = associatedTokenAccount(expectation.depositAddress, token.id) const transfer = transaction.instructions[transaction.instructions.length - 1] as Instruction const creation = transaction.instructions[transaction.instructions.length - 2] if (steps[steps.length - 2] === 'create' && creation !== undefined) { @@ -282,14 +290,19 @@ async function usdcBalance(account: string): Promise { * Funds sent to a token account's own token account can't be moved by anyone, since a * token account has no key to sign with. A missing account is fine: it is a fresh wallet. */ -async function assertDepositIsNotTokenAccount(depositAddress: string): Promise { +async function assertDepositIsNotTokenAccount( + depositAddress: string, + token: ChainToken +): Promise { const reply = RpcAccount.safeParse( await rpc('getAccountInfo', [depositAddress, { encoding: 'base64', commitment: 'confirmed' }]) ) if (!reply.success) refuse('Solana’s network could not be read just now.') const owner = reply.data.result.value?.owner if (owner === TOKEN_PROGRAM || owner === TOKEN_2022_PROGRAM) { - refuse('This order’s deposit address is a token account, and USDC sent there would be stuck.') + refuse( + `This order’s deposit address is a token account, and ${token.symbol} sent there would be stuck.` + ) } } @@ -299,7 +312,8 @@ function compile( lifetime: { blockhash: string; lastValidBlockHeight: number } ): string { const { account, depositAddress } = expectation - const destination = associatedTokenAccount(depositAddress, USDC.id) + const token = tokenOf(expectation) + const destination = associatedTokenAccount(depositAddress, token.id) const message = pipe( createTransactionMessage({ version: 'legacy' }), (m) => setTransactionMessageFeePayer(account as Address, m), @@ -334,7 +348,7 @@ function compile( { address: account as Address, role: AccountRole.WRITABLE_SIGNER }, { address: destination as Address, role: AccountRole.WRITABLE }, { address: depositAddress as Address, role: AccountRole.READONLY }, - { address: USDC.id as Address, role: AccountRole.READONLY }, + { address: token.id as Address, role: AccountRole.READONLY }, { address: SYSTEM_PROGRAM as Address, role: AccountRole.READONLY }, { address: TOKEN_PROGRAM as Address, role: AccountRole.READONLY }, ], @@ -344,17 +358,17 @@ function compile( programAddress: TOKEN_PROGRAM as Address, accounts: [ { - address: associatedTokenAccount(account, USDC.id) as Address, + address: associatedTokenAccount(account, token.id) as Address, role: AccountRole.WRITABLE, }, - { address: USDC.id as Address, role: AccountRole.READONLY }, + { address: token.id as Address, role: AccountRole.READONLY }, { address: destination as Address, role: AccountRole.WRITABLE }, { address: account as Address, role: AccountRole.READONLY_SIGNER }, ], data: Uint8Array.from([ TRANSFER_CHECKED, ...getU64Encoder().encode(units), - USDC.decimals, + token.decimals, ]), }, ], @@ -366,7 +380,7 @@ function compile( async function build(expectation: DepositExpectation): Promise { const units = orderUnits(expectation) - await assertDepositIsNotTokenAccount(expectation.depositAddress) + await assertDepositIsNotTokenAccount(expectation.depositAddress, tokenOf(expectation)) const reply = RpcBlockhash.safeParse( await rpc('getLatestBlockhash', [{ commitment: 'confirmed' }]) ) @@ -380,11 +394,11 @@ async function build(expectation: DepositExpectation): Promise { return payload } -function pending(hash: string): SubmitResult { +function pending(hash: string, token: ChainToken): SubmitResult { return { ok: false, pending: true, - error: `Your USDC deposit was sent (${hash}) but isn't confirmed yet. Don't send it again until you've checked ${explorerUrl(hash)}.`, + error: `Your ${token.symbol} deposit was sent (${hash}) but isn't confirmed yet. Don't send it again until you've checked ${explorerUrl(hash)}.`, } } @@ -409,9 +423,9 @@ async function submit( result = await createSolanaSubmit()({ account: expectation.account, venueId: 'weave', signed }) } catch { // A connection that broke during the send says nothing about whether it landed. - return pending(hash) + return pending(hash, tokenOf(expectation)) } - return !result.ok && result.pending === true ? pending(hash) : result + return !result.ok && result.pending === true ? pending(hash, tokenOf(expectation)) : result } export const solanaDeposit: DepositChain = { diff --git a/apps/dapp/lib/weave/deposit/sui.ts b/apps/dapp/lib/weave/deposit/sui.ts index e6ca031b..40f94a77 100644 --- a/apps/dapp/lib/weave/deposit/sui.ts +++ b/apps/dapp/lib/weave/deposit/sui.ts @@ -27,7 +27,14 @@ type TxCommand = TxData['commands'][number] type TxArgument = Extract['SplitCoins']['coin'] const USDC = tokenBySymbol(SUI_TOKENS, 'USDC') as ChainToken -const USDC_COIN = normalizeStructTag(`0x2::coin::Coin<${USDC.id}>`) + +function tokenOf(expectation: DepositExpectation): ChainToken { + return expectation.token ?? USDC +} + +function coinTypeOf(token: ChainToken): string { + return normalizeStructTag(`0x2::coin::Coin<${token.id}>`) +} const MAX_U64 = BigInt('18446744073709551615') const READ_TIMEOUT_MS = 10_000 @@ -49,13 +56,14 @@ function orderUnits(expectation: DepositExpectation): string { if (normalizeSuiAddress(depositAddress) === normalizeSuiAddress(account)) { refuse('This order’s deposit address is your own wallet.') } + const token = tokenOf(expectation) let units: string try { - units = toBaseUnits(amount, USDC.decimals) + units = toBaseUnits(amount, token.decimals) } catch (e) { return refuse(e instanceof Error ? e.message : 'That is not an amount.') } - if (BigInt(units) > MAX_U64) refuse('This order’s amount is too large for USDC.') + if (BigInt(units) > MAX_U64) refuse(`This order’s amount is too large for ${token.symbol}.`) return units } @@ -106,6 +114,7 @@ function isResultOf(argument: TxArgument | undefined, command: number): boolean */ function spentCoins(transaction: string, expectation: DepositExpectation): string[] { const units = orderUnits(expectation) + const { symbol } = tokenOf(expectation) const me = normalizeSuiAddress(expectation.account) const data = parse(transaction) @@ -125,14 +134,14 @@ function spentCoins(transaction: string, expectation: DepositExpectation): strin const transfer = data.commands[splitAt + 1] const merges = data.commands.slice(0, Math.max(splitAt, 0)) if (split?.$kind !== 'SplitCoins' || transfer?.$kind !== 'TransferObjects') { - return refuse('This transaction does more than the one USDC transfer this order needs.') + return refuse(`This transaction does more than the one ${symbol} transfer this order needs.`) } const coin = coinInput(data, split.SplitCoins.coin) const coins = new Set([coin]) for (const merge of merges) { if (merge.$kind !== 'MergeCoins') { - return refuse('This transaction does more than the one USDC transfer this order needs.') + return refuse(`This transaction does more than the one ${symbol} transfer this order needs.`) } if (coinInput(data, merge.MergeCoins.destination) !== coin) { refuse('This transaction merges coins into something other than the coin it pays from.') @@ -186,17 +195,23 @@ async function read(work: Promise): Promise { } } -async function coinsCovering(account: string, units: bigint): Promise { +async function coinsCovering( + account: string, + units: bigint, + expectation: DepositExpectation +): Promise { const { core } = suiClient() + const token = tokenOf(expectation) + const wanted = coinTypeOf(token) const picked: SuiClientTypes.Coin[] = [] let total = BigInt(0) let cursor: string | null = null for (let page = 0; page < MAX_COIN_PAGES; page += 1) { const reply: SuiClientTypes.ListCoinsResponse = await read( - core.listCoins({ owner: account, coinType: USDC.id, limit: COINS_PER_PAGE, cursor }) + core.listCoins({ owner: account, coinType: token.id, limit: COINS_PER_PAGE, cursor }) ) for (const coin of reply.objects) { - if (total < units && normalizeStructTag(coin.type) === USDC_COIN) { + if (total < units && normalizeStructTag(coin.type) === wanted) { picked.push(coin) total += BigInt(coin.balance) } @@ -206,19 +221,22 @@ async function coinsCovering(account: string, units: bigint): Promise= units) { + // Weave's orders name no token, and a person there is cashing out rather than swapping. + const action = expectation.token === undefined ? 'cash-out' : 'swap' refuse( - 'Your USDC is in your Sui address balance or across too many small coins, and this cash-out can only spend coins.' + `Your ${token.symbol} is in your Sui address balance or across too many small coins, and this ${action} can only spend coins.` ) } - return refuse('This wallet holds less USDC than this order’s amount.') + return refuse(`This wallet holds less ${token.symbol} than this order’s amount.`) } async function build(expectation: DepositExpectation): Promise { const units = orderUnits(expectation) const me = normalizeSuiAddress(expectation.account) - const coins = await coinsCovering(me, BigInt(units)) + const token = tokenOf(expectation) + const coins = await coinsCovering(me, BigInt(units), expectation) const tx = new Transaction() tx.setSender(me) @@ -226,14 +244,16 @@ async function build(expectation: DepositExpectation): Promise { const [coin, ...others] = coins.map((c) => tx.object(Inputs.ObjectRef({ objectId: c.objectId, version: c.version, digest: c.digest })) ) - if (coin === undefined) return refuse('This wallet holds less USDC than this order’s amount.') + if (coin === undefined) { + return refuse(`This wallet holds less ${token.symbol} than this order’s amount.`) + } if (others.length > 0) tx.mergeCoins(coin, others) const [split] = tx.splitCoins(coin, [BigInt(units)]) tx.transferObjects([split as never], normalizeSuiAddress(expectation.depositAddress)) let transaction: string try { - transaction = await buildUnsigned(tx, suiClient(), USDC.symbol) + transaction = await buildUnsigned(tx, suiClient(), token.symbol) } catch (e) { return refuse( e instanceof ChainActionRefused ? e.message : 'Sui’s network could not build this just now.' @@ -244,20 +264,25 @@ async function build(expectation: DepositExpectation): Promise { return payload } -/** What the bytes can't show: that each coin spent really is USDC, and the account's own. */ -async function assertSpendsOwnUsdc(ids: string[], account: string): Promise { +/** What the bytes can't show: that each coin spent really is the token, and the account's own. */ +async function assertSpendsOwnToken( + ids: string[], + account: string, + token: ChainToken +): Promise { const me = normalizeSuiAddress(account) + const wanted = coinTypeOf(token) const { objects } = await read(suiClient().core.getObjects({ objectIds: ids })) - const allOwnUsdc = + const allOwn = objects.length === ids.length && objects.every( (object) => !(object instanceof Error) && - normalizeStructTag(object.type) === USDC_COIN && + normalizeStructTag(object.type) === wanted && object.owner.$kind === 'AddressOwner' && normalizeSuiAddress(object.owner.AddressOwner) === me ) - if (!allOwnUsdc) refuse('A coin in this transaction is not USDC held by your wallet.') + if (!allOwn) refuse(`A coin in this transaction is not ${token.symbol} held by your wallet.`) } async function submit( @@ -267,16 +292,16 @@ async function submit( if (signed.family !== 'sui') { return { ok: false, error: 'That is not a Sui transaction, so nothing was sent.' } } + const token = tokenOf(expectation) const ids = spentCoins(signed.transaction, expectation) - await assertSpendsOwnUsdc(ids, expectation.account) + await assertSpendsOwnToken(ids, expectation.account, token) try { const result = await submitSui({ account: expectation.account, venueId: 'weave', signed }) - // submitSui words a failure for a trade, which a person cashing out never made. + // submitSui words a failure for a trade, and what failed here is only the deposit. return !result.ok && result.pending !== true && result.error.includes('trade') ? { ok: false, - error: - 'This deposit failed on the network, so your USDC did not move and only the network fee was spent. You can try again.', + error: `This deposit failed on the network, so your ${token.symbol} did not move and only the network fee was spent. You can try again.`, } : result } catch { @@ -285,7 +310,7 @@ async function submit( return { ok: false, pending: true, - error: `Your USDC deposit was sent (${digest}) but isn't confirmed yet. Don't send it again until you've checked https://suiscan.xyz/mainnet/tx/${digest}.`, + error: `Your ${token.symbol} deposit was sent (${digest}) but isn't confirmed yet. Don't send it again until you've checked https://suiscan.xyz/mainnet/tx/${digest}.`, } } } diff --git a/apps/dapp/lib/weave/types.ts b/apps/dapp/lib/weave/types.ts index b6b3e1c6..94d01eb0 100644 --- a/apps/dapp/lib/weave/types.ts +++ b/apps/dapp/lib/weave/types.ts @@ -1,4 +1,4 @@ -import type { SignedPayload, SigningPayload, SubmitResult } from '../chains/types' +import type { ChainToken, SignedPayload, SigningPayload, SubmitResult } from '../chains/types' /** The chains a person can cash out from through Weave. Only USDC, on every one of them. */ export type CashoutChain = 'stellar' | 'solana' | 'avalanche' | 'sui' | 'near' @@ -41,8 +41,10 @@ export interface DepositExpectation { depositAddress: string /** Absent when the order has no memo. Refused by chains that cannot carry one. */ memo: string | undefined - /** USDC in display units. */ + /** The token in display units. */ amount: string + /** What to transfer. Absent means USDC, which is all a Weave order ever sends. */ + token?: ChainToken | undefined } /** diff --git a/apps/dapp/public/images/venues/1click-solana.webp b/apps/dapp/public/images/venues/1click-solana.webp new file mode 100644 index 0000000000000000000000000000000000000000..bfb03c75d11fe921eb319e403dd916113930f69f GIT binary patch literal 1234 zcmV;@1TFhgNk&G>1ONb6MM6+kP&gpI1ONcg9sr#IDu4ih06sk&jYJ|Lp_EI+gdhXN zprLGpHvrRL_;bmHrTxzRoc|4&2jCx>|2X`Q|0UW}!~^)lz<s|C($eZ!UmJRA6(#kZOcmkO{gPPyRBk!iQ5DtE^z{3ES zIc>ntho_25Q0mNqL52@rB?Ww1IQeR}n5sXZm5b|uc+_?6F}=1fsLWo0h-EqV?ay2K zz4vpj_RgBA*%v6}#OoX$)xMOt@xPJMcl)oI?@itz0ra*9zwxFhrY{9N%wTQH{Ssvw zSYzh)=caTazuWij=_H);TePyP#xZjg;O3hDbg@`unBZf-h&@vB4pVeN?Y>gTWvcE4 zMVk(Bu}dl2RCXJ__V5@#sCK9QNs5!r2KU$0hCt>^v_ouwRr)w2VVH-LozR`)*4h45 zj~!$~4glLYmG3lFke{^kYouUQ-3HZMZ<>SXcVTDw9N zvG6PoMfv4&M{P&9x$^`bK6<(=xtWyimtftBb?o6M5`Ti@!H3OFbiYrHAy9fHXkD&R zYphw&H&~Kv#QUCj0YY*PC;#`K5U*hQNwNBto^9xDtJF2m$P<#p%0{AfQim&WZSTgc zvo0hVYBX{`pb0REWi`CxzwPUsiR~@Tl{ACG1nkl9#57?iEdb<-=}YFGJ5r<<0@~(j z_qiAq?3XORXL2dM&TPFWLtQ5GA^FQ~2<4Vm(2fDg`dkZ&dLB4p@yiLuK5$N{;X_WM z52qiIDAn-C;+2tf!s{%o(a7pIBW$xtt=|+0MePWd<65zi{VK@qK zoi3J-yq{NyAvmFvUV0G#1})m}n%YGFwjCqo02(edxNuLNRISriyP{XXkN5D*!=9ZL z!kG#+S&UaSZHMfTYB4fFG{(?wd+^z2){&%B&HcM0{y(-yiin1qZUV1ONb6MM6+kP&gpI1ONcg9sr#IDu4ih06sk&jYJ|Lp_EI+gdhXN zprLGpHvrRL_;bmHrTxzRoc|4&2jCx>|2X`Q|0UW}!~^)lz<s|C($eZ!UmJRA6(#kZOcmkO{gPPyRBk!iQ5DtE^z{3ES zIc>ntho_25Q0mNqL52@rB?Ww1IQeR}n5sXZm5b|uc+_?6F}=1fsLWo0h-EqV?ay2K zz4vpj_RgBA*%v6}#OoX$)xMOt@xPJMcl)oI?@itz0ra*9zwxFhrY{9N%wTQH{Ssvw zSYzh)=caTazuWij=_H);TePyP#xZjg;O3hDbg@`unBZf-h&@vB4pVeN?Y>gTWvcE4 zMVk(Bu}dl2RCXJ__V5@#sCK9QNs5!r2KU$0hCt>^v_ouwRr)w2VVH-LozR`)*4h45 zj~!$~4glLYmG3lFke{^kYouUQ-3HZMZ<>SXcVTDw9N zvG6PoMfv4&M{P&9x$^`bK6<(=xtWyimtftBb?o6M5`Ti@!H3OFbiYrHAy9fHXkD&R zYphw&H&~Kv#QUCj0YY*PC;#`K5U*hQNwNBto^9xDtJF2m$P<#p%0{AfQim&WZSTgc zvo0hVYBX{`pb0REWi`CxzwPUsiR~@Tl{ACG1nkl9#57?iEdb<-=}YFGJ5r<<0@~(j z_qiAq?3XORXL2dM&TPFWLtQ5GA^FQ~2<4Vm(2fDg`dkZ&dLB4p@yiLuK5$N{;X_WM z52qiIDAn-C;+2tf!s{%o(a7pIBW$xtt=|+0MePWd<65zi{VK@qK zoi3J-yq{NyAvmFvUV0G#1})m}n%YGFwjCqo02(edxNuLNRISriyP{XXkN5D*!=9ZL z!kG#+S&UaSZHMfTYB4fFG{(?wd+^z2){&%B&HcM0{y(-yiin1qZUV Date: Wed, 4 Nov 2026 13:18:17 +0100 Subject: [PATCH 13/24] fix(dapp): value 1click like every other route and answer inside the submit limit The race now prices every Solana and Sui route from the chain's source, and takes the Solana deposit's rent off 1Click's value. A native output never matches a wrapped mint. Jupiter and the chain route's Solana submit answer pending inside 60 seconds, and the hook reads an unreadable answer as pending. The Apps rows list 1Click without claiming it runs, as the rows can't read the secret, and the module wires it only with that secret. --- apps/dapp/hooks/use-chain-action.ts | 16 +- .../lib/__tests__/chain-signing-flow.test.ts | 41 ++- apps/dapp/lib/__tests__/near-swap.test.ts | 7 + .../lib/__tests__/oneclick-origin.test.ts | 112 +++++++- apps/dapp/lib/__tests__/solana-submit.test.ts | 97 ++++++- .../__tests__/solana-sui-race-quote.test.ts | 252 +++++++++++++++++- apps/dapp/lib/__tests__/solana-venues.test.ts | 10 +- apps/dapp/lib/__tests__/sui-module.test.ts | 20 +- apps/dapp/lib/agents/chain-routes.ts | 10 + apps/dapp/lib/chains/near/swap.ts | 20 +- apps/dapp/lib/chains/oneclick-origin.ts | 95 +++++-- apps/dapp/lib/chains/signing-flow.ts | 18 ++ apps/dapp/lib/chains/solana/jupiter-swap.ts | 32 ++- apps/dapp/lib/chains/solana/module.ts | 9 +- apps/dapp/lib/chains/solana/submit.ts | 43 ++- apps/dapp/lib/chains/solana/venues.ts | 4 +- apps/dapp/lib/chains/sui/module.ts | 17 +- apps/dapp/lib/chains/sui/venues.ts | 20 +- apps/dapp/lib/chains/types.ts | 6 + 19 files changed, 725 insertions(+), 104 deletions(-) diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index a5917149..fb802a10 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -8,6 +8,7 @@ import { afterSigning, afterSubmitting, latchBeforeSign, + submitSigned, } from '../lib/chains/signing-flow' import { useChain } from '../providers/chain-provider' import { useWallet } from './use-wallet' @@ -177,17 +178,12 @@ export function useChainAction(): ChainAction { setSigned(true) setState((s) => ({ ...s, phase: 'submitting' })) - const res = await fetch(`/api/chain/${slug}/submit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - account, - venueId: prepared.venueId, - signed: step.signed, - ...(prepared.context !== undefined ? { context: prepared.context } : {}), - }), + const result = await submitSigned(`/api/chain/${slug}/submit`, { + account, + venueId: prepared.venueId, + signed: step.signed, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), }) - const result = (await res.json()) as unknown if (!current()) return const done = afterSubmitting(result, family) if (!done.latched) setSigned(false) diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts index 8395e746..28146bed 100644 --- a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { accountMismatch, afterSigning, afterSubmitting, latchBeforeSign, + submitSigned, } from '../chains/signing-flow' import { panelErrorMessage, reviewChainRoute } from '../agents/chain-race' import type { ChainActionRequest } from '../chains/types' @@ -172,6 +173,44 @@ describe('unknown outcomes keep the latch and Review closed', () => { }) }) +describe('an answer that can not be read, once a signature has gone to the server', () => { + afterEach(() => vi.unstubAllGlobals()) + + const request = { account: 'a', venueId: 'v' } + + it.each([ + ['the platform killing the function', () => new Response('504', { status: 504 })], + ['an empty answer', () => new Response('', { status: 200 })], + [ + 'the connection dropping', + () => { + throw new TypeError('fetch failed') + }, + ], + ])('holds the latch after %s', async (_name, answer) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => answer()) + ) + const result = await submitSigned('/api/chain/solana/submit', request) + for (const family of ['solana', 'sui'] as const) { + expect(afterSubmitting(result, family)).toMatchObject({ phase: 'pending', latched: true }) + } + }) + + it('posts the body as JSON and hands back a readable answer as it is', async () => { + const answer = { ok: false, error: 'The quote moved too far.' } + const fetchMock = vi.fn(async () => new Response(JSON.stringify(answer), { status: 422 })) + vi.stubGlobal('fetch', fetchMock) + await expect(submitSigned('/api/chain/solana/submit', request)).resolves.toEqual(answer) + expect(fetchMock).toHaveBeenCalledWith('/api/chain/solana/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + }) +}) + describe('panelErrorMessage', () => { const said = 'No route could be priced right now. Nothing was signed or sent.' it('keeps the sentence while nothing may be signed', () => { diff --git a/apps/dapp/lib/__tests__/near-swap.test.ts b/apps/dapp/lib/__tests__/near-swap.test.ts index bfd4beab..f6692ad3 100644 --- a/apps/dapp/lib/__tests__/near-swap.test.ts +++ b/apps/dapp/lib/__tests__/near-swap.test.ts @@ -270,6 +270,13 @@ describe('preparing a 1Click swap', () => { expect(prepared.notes.join(' ')).toMatch(/minus a fee/) }) + it('keeps NEAR’s cost sentence word for word', async () => { + const prepared = await oneClickVenue.prepare(swap(), ME) + expect(prepared.notes[0]).toMatch( + /^NEAR Intents runs on mainnet only, so this moves real funds\. .* After all costs you get about [\d.]+% less in value than you pay, and at least / + ) + }) + it('sends the API key and app fees only when configured', async () => { await oneClickVenue.prepare(swap(), ME) expect(sim.quoteHeaders[0]).not.toHaveProperty('X-API-Key') diff --git a/apps/dapp/lib/__tests__/oneclick-origin.test.ts b/apps/dapp/lib/__tests__/oneclick-origin.test.ts index e8bfd3a0..98441fbc 100644 --- a/apps/dapp/lib/__tests__/oneclick-origin.test.ts +++ b/apps/dapp/lib/__tests__/oneclick-origin.test.ts @@ -37,6 +37,11 @@ import { type SubmitResult, } from '../chains/types' +// The real modules are built once on load, and only offer 1Click when the secret is there. +vi.hoisted(() => { + process.env['AUTH_SECRET'] = 'x'.repeat(48) +}) + vi.mock('../chains/near/quote-signature', () => ({ verifyQuoteSignature: vi.fn(() => true), })) @@ -408,11 +413,46 @@ describe('oneClickAssetId', () => { await expect(oneClickAssetId('solana', SOL_USDC)).resolves.toBeUndefined() }) - it('finds a native coin listed under its contract too', async () => { - sim.tokens = [{ ...tokenList()[2], contractAddress: SOL_NATIVE.id }] + it('never pays out wrapped SOL for SOL, even when 1Click lists the wrapped mint', async () => { + const native = tokenList()[2] as Json + const wrapped = { ...native, assetId: 'nep141:wsol.omft.near', contractAddress: SOL_NATIVE.id } + sim.tokens = [wrapped] + await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBeUndefined() + clearTokenCache() + sim.tokens = [wrapped, native] await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') }) + it('refuses a swap into SOL when only the wrapped mint is listed', async () => { + const native = tokenList()[2] as Json + sim.tokens = [ + ...tokenList().filter((t) => t['assetId'] !== native['assetId']), + { ...native, assetId: 'nep141:wsol.omft.near', contractAddress: SOL_NATIVE.id }, + ] + await expect(solana.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.quotes).toHaveLength(0) + }) + + it('takes SUI under its own coin type, which is the native coin itself', async () => { + const native = tokenList()[6] as Json + sim.tokens = [{ ...native, contractAddress: `0x${'2'.padStart(64, '0')}::sui::SUI` }] + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBe('nep141:sui.omft.near') + }) + + it('never takes another coin called SUI for SUI', async () => { + const native = tokenList()[6] as Json + const wrapped = { + ...native, + assetId: 'nep141:wsui.omft.near', + contractAddress: `${normalizeSuiAddress('0xbad')}::wsui::SUI`, + } + sim.tokens = [wrapped] + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBeUndefined() + clearTokenCache() + sim.tokens = [wrapped, native] + await expect(oneClickAssetId('sui', SUI_NATIVE)).resolves.toBe('nep141:sui.omft.near') + }) + it('still reads a list whose contract is null, so NEAR prices never break on it', async () => { sim.tokens = [{ ...tokenList()[0] }, { ...tokenList()[2], contractAddress: null }] await expect(oneClickAssetId('solana', SOL_NATIVE)).resolves.toBe('nep141:sol.omft.near') @@ -424,8 +464,9 @@ describe('quote', () => { const quoted = await solana.quote?.(swap(), ME) expect(quoted).toEqual({ receive: { amount: '0.25', symbol: 'SOL' }, - valueOutUsd: 24.875, + walletCost: { amount: '0.00203928', symbol: 'SOL' }, }) + expect(quoted).not.toHaveProperty('valueOutUsd') expect(sim.quotes).toHaveLength(1) expect(sim.quotes[0]).toMatchObject({ dry: true, @@ -440,6 +481,12 @@ describe('quote', () => { expect(JSON.stringify(quoted)).not.toContain(DEPOSIT) }) + it('has no rent to report on Sui, and leaves the valuing to the chain there too', async () => { + await expect(sui.quote?.(suiSwap(), SUI_ME)).resolves.toEqual({ + receive: { amount: '12.5', symbol: 'SUI' }, + }) + }) + it.each([ ['solana', 'SOL', 'USDC', ME], ['sui', 'SUI', 'USDC', SUI_ME], @@ -517,6 +564,37 @@ describe('prepare', () => { expect(() => solana.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow() }) + it('tells the truth about costs on Solana, including rent it can not get back', async () => { + const { notes } = await solana.prepare(swap(), ME) + const text = notes.join(' ') + expect(text).toMatch(/0\.00204 SOL/) + expect(text).toMatch(/rent/) + expect(text).toMatch(/don't get it back/) + expect(text).not.toMatch(/after all costs/i) + expect(text).toMatch(/before your wallet's network costs/) + }) + + it('still gives the rent note when SOL has no price anywhere', async () => { + sim.tokens = tokenList().map((t) => + t['assetId'] === 'nep141:sol.omft.near' ? { ...t, price: undefined } : t + ) + const { notes } = await solana.prepare(swap({ tokenOut: 'USDT' }), ME) + expect(notes.join(' ')).toMatch(/0\.00204 SOL/) + }) + + it('says nothing about rent on Sui, which has none, and no NEAR registration', async () => { + sim.depositAddress = SUI_DEPOSIT + sim.quoteOverride = (body) => { + const good = goodQuote(body) + return { ...good, quote: { ...(good['quote'] as Json), withdrawFee: '1000' } } + } + const text = (await sui.prepare(suiSwap(), SUI_ME)).notes.join(' ') + expect(text).not.toMatch(/rent/) + expect(text).not.toMatch(/after all costs/i) + expect(text).not.toMatch(/register/) + expect(text).toMatch(/to deliver it to you/) + }) + it('spends the token asked for, not USDC by assumption', async () => { const prepared = await solana.prepare(swap({ tokenIn: 'USDT' }), ME) expect(() => solana.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow() @@ -700,6 +778,34 @@ describe('a browser that changes what it sends back', () => { ) }) + describe('with the right key', () => { + const signWith = (message: string): string => + createHmac('sha256', 'x'.repeat(48)).update(message).digest('base64url') + + const issuedMessage = (context: Record): string => + JSON.stringify([ + '1click-solana', + ME, + SOL_USDC.id, + '25000000', + context['depositAddress'], + context['deadline'], + ]) + + it('rebuilds the real proof, so the case below tests only the prefix', () => { + const context: Record = { ...prepared.context } + expect(signWith(`oneclick-origin:${issuedMessage(context)}`)).toBe(context['proof']) + }) + + it('refuses a signature made without the prefix, as any other use of the key would be', () => { + const context: Record = { ...prepared.context, depositAddress: THIEF } + const proof = signWith(issuedMessage(context)) + expect(() => solana.assertSafe(payload({ to: THIEF }), ME, { ...context, proof })).toThrow( + /not what this app issued/ + ) + }) + }) + it('cannot reuse a proof for another account or another chain', async () => { // Bytes that are a perfect transfer from the other account, so only the proof can refuse them. expect(() => solana.assertSafe(payload({ owner: THIEF }), THIEF, prepared.context)).toThrow( diff --git a/apps/dapp/lib/__tests__/solana-submit.test.ts b/apps/dapp/lib/__tests__/solana-submit.test.ts index 9c666d34..267ab5e8 100644 --- a/apps/dapp/lib/__tests__/solana-submit.test.ts +++ b/apps/dapp/lib/__tests__/solana-submit.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { afterSubmitting } from '../chains/signing-flow' import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap' +import { solanaModule } from '../chains/solana/module' import { createSolanaSubmit } from '../chains/solana/submit' import { CAPTURE_ACCOUNT, @@ -634,12 +635,104 @@ describe('jupiter execute', () => { await expect(venue.submit?.(executeInput)).resolves.toEqual({ ok: false, error: wording }) }) - it('leaves an unknown outcome to the route, which tells the user to check their wallet', async () => { + it('leaves a server failure to the route, which tells the user to check their wallet', async () => { answering({}, 500) await expect(venue.submit?.(executeInput)).rejects.toThrow() + }) + + it('answers for itself when execute times out, naming the swap to check', async () => { vi.stubGlobal('fetch', async () => { throw new DOMException('timed out', 'TimeoutError') }) - await expect(venue.submit?.(executeInput)).rejects.toThrow() + const result = await venue.submit?.(executeInput) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(JSON.stringify(result)).toContain(`https://solscan.io/tx/${swapSigned.signature}`) + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('gives up on execute inside the route’s 60 seconds', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + answering({ status: 'Success', signature: swapSigned.signature }) + await venue.submit?.(executeInput) + expect(timeout.mock.calls.map(([ms]) => ms).every((ms) => ms <= 50_000)).toBe(true) + expect(timeout).toHaveBeenCalled() + timeout.mockRestore() + }) +}) + +describe('the chain route\u2019s deadline on a Solana submit', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + const fakeTime = (): number => { + const startedAt = Date.now() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: startedAt }) + return startedAt + } + + async function settle(pending: Promise): Promise { + let done = false + void pending.finally(() => { + done = true + }) + for (let i = 0; i < 400 && !done; i += 1) await vi.advanceTimersByTimeAsync(1_000) + return pending + } + + it('answers pending within the route’s limit when the status never comes', async () => { + const { calls } = stubRpc((method) => + method === 'sendTransaction' ? ok(signed.signature) : status(null) + ) + const startedAt = fakeTime() + const result = await settle(solanaModule.submit(input(signed.transaction))) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(Date.now() - startedAt).toBeLessThan(55_000) + expect(calls.map((c) => c.method)).not.toContain('isBlockhashValid') + expect(afterSubmitting(result, 'solana')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it('stays inside the limit when every read is slow, and shrinks what each may take', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + const { method } = JSON.parse(String(init.body)) as { method: string } + if (method === 'sendTransaction') { + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: signed.signature })) + } + await new Promise((resolve) => setTimeout(resolve, 14_000)) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, ...status(null) })) + }) + const startedAt = fakeTime() + const result = await settle(solanaModule.submit(input(signed.transaction))) + expect(result).toMatchObject({ ok: false, pending: true }) + // This stub ignores the abort, so the time taken here overstates what a real read would. + expect(Date.now() - startedAt).toBeLessThan(50_000 + 2_000 + 14_000) + const last = timeout.mock.calls.at(-1)?.[0] as number + expect(last).toBeLessThan(2_000) + }) + + it('still reports a confirmation inside the limit', async () => { + stubRpc((method) => + method === 'sendTransaction' + ? ok(signed.signature) + : status({ confirmationStatus: 'confirmed', err: null }) + ) + fakeTime() + await expect(settle(solanaModule.submit(input(signed.transaction)))).resolves.toMatchObject({ + ok: true, + }) + }) + + it('leaves a submit without a deadline polling as long as before', async () => { + const { calls } = stubRpc((method) => { + if (method === 'sendTransaction') return ok(signed.signature) + return method === 'isBlockhashValid' ? ok({ value: true }) : status(null) + }) + const startedAt = fakeTime() + const result = await settle(createSolanaSubmit({ env: {} })(input(signed.transaction))) + expect(result).toMatchObject({ ok: false, pending: true }) + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(60_000) + expect(calls.map((c) => c.method)).toContain('isBlockhashValid') }) }) diff --git a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts index b8fb06ef..abd44e4f 100644 --- a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts +++ b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { quoteChainRoutes } from '../agents/chain-routes' +import type { AgentProposalResult } from '../agents/brain' +import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes' +import { planScore } from '../agents/plan-scoring' +import { applyChainMeasure, isUnmeasuredSwap } from '../agents/settle-chain-plan' import { clearTokenCache } from '../chains/near/oneclick' import { oneClickOriginVenue } from '../chains/oneclick-origin' import { @@ -14,7 +17,7 @@ import { createLuloVenue } from '../chains/solana/lulo' import { buildSolanaVenues, solanaModule } from '../chains/solana/module' import { solanaVenues } from '../chains/solana/venues' import { cetusVenue } from '../chains/sui/cetus' -import { suiModule } from '../chains/sui/module' +import { buildSuiVenues, suiModule } from '../chains/sui/module' import { naviVenue } from '../chains/sui/navi' import { springsuiVenue } from '../chains/sui/springsui' import { suiVenues } from '../chains/sui/venues' @@ -406,22 +409,47 @@ describe('the race on these chains', () => { }) it('wires 1Click as a swap venue on both chains, keeping the defaults', () => { - expect(buildSolanaVenues({}).find((v) => v.id === '1click-solana')?.kinds).toEqual(['swap']) - expect(suiModule.venues.find((v) => v.id === '1click-sui')?.kinds).toEqual(['swap']) + const withSecret = { AUTH_SECRET: 'x'.repeat(48) } + expect(buildSolanaVenues(withSecret).find((v) => v.id === '1click-solana')?.kinds).toEqual([ + 'swap', + ]) + expect(buildSuiVenues(withSecret).find((v) => v.id === '1click-sui')?.kinds).toEqual(['swap']) expect(solanaModule.defaultVenue.swap).toBe('jupiter') expect(suiModule.defaultVenue.swap).toBe('cetus') }) + it.each([ + ['no secret', {}], + ['one too short to trust', { AUTH_SECRET: 'short' }], + ])('wires no 1Click at all with %s, as Lulo waits for its key', (_name, env) => { + expect(buildSolanaVenues(env).map((v) => v.id)).not.toContain('1click-solana') + expect(buildSuiVenues(env).map((v) => v.id)).not.toContain('1click-sui') + }) + it('gives every venue on these chains a quote to race with', () => { const lulo = createLuloVenue({ apiKey: 'k' }) - for (const venue of [...buildSolanaVenues({}), lulo, ...suiModule.venues]) { + const env = { AUTH_SECRET: 'x'.repeat(48) } + for (const venue of [...buildSolanaVenues(env), lulo, ...buildSuiVenues(env)]) { expect(venue.quote, venue.id).toBeTypeOf('function') } }) - it('lists 1Click on the Apps page of both chains', () => { - expect(solanaVenues.find((v) => v.id === '1click-solana')?.integration).toBe('executes') - expect(suiVenues.find((v) => v.id === '1click-sui')?.integration).toBe('executes') + it('lists 1Click on the Apps page without claiming it executes, as Lulo does', () => { + // These rows reach the browser, which can't read the secret that decides it. + for (const row of [ + solanaVenues.find((v) => v.id === '1click-solana'), + suiVenues.find((v) => v.id === '1click-sui'), + ]) { + expect(row).toBeDefined() + expect(row?.integration).toBeUndefined() + expect(row?.capability).toBeUndefined() + expect(row?.bestFor).toMatch(/USDC or USDT/) + } + }) + + it('makes no claim on Sui about which tokens 1Click lists today', () => { + const row = suiVenues.find((v) => v.id === '1click-sui') + expect(JSON.stringify(row)).not.toMatch(/the pair|lists here/) }) it('says agents compete on Solana and Sui, and nothing these chains cannot do', () => { @@ -434,3 +462,211 @@ describe('the race on these chains', () => { expect(onSui).not.toMatch(/Send:|limit order/) }) }) + +describe('what the race compares', () => { + const swapToSui = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' } as const + const updated = (): string => new Date().toISOString() + const list = () => [ + { + assetId: 'nep141:sui-usdc', + blockchain: 'sui', + symbol: 'USDC', + decimals: 6, + price: 1, + priceUpdatedAt: updated(), + contractAddress: USDC_SUI, + }, + { + assetId: 'nep141:sui', + blockchain: 'sui', + symbol: 'SUI', + decimals: 9, + price: 2, + priceUpdatedAt: updated(), + }, + ] + + function suiRace(amountOut: string, ownUsd: string): void { + stubFetch((url, init) => { + if (url.endsWith('/v0/tokens')) return { body: list() } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Record + return { + status: 201, + body: { + quote: { + amountIn: body['amount'], + amountInFormatted: '10', + amountInUsd: '10', + minAmountIn: body['amount'], + amountOut, + amountOutFormatted: 'x', + amountOutUsd: ownUsd, + minAmountOut: ((BigInt(amountOut) * BigInt(995)) / BigInt(1000)).toString(), + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + }, + } + } + return undefined + }) + } + + const suiChain = (): ChainModule => ({ + ...suiModule, + priceUsd: async (t) => (t.symbol === 'SUI' ? 2 : 1), + venues: [cetusVenue, oneClickOriginVenue('sui')], + }) + + /** Each route's score, as the race ranks them once the agents have picked. */ + async function scores(): Promise> { + const routes = await quoteChainRoutes(suiChain(), swapToSui, SUI_ME) + const proposal = { + projectedAvgPriceUsd: 0, + projectedSlippagePct: 0, + executionMode: 'fill', + sliceCount: 1, + } as unknown as AgentProposalResult + return Object.fromEntries( + routes.map((r) => { + const measured = applyChainMeasure( + { ...proposal, routeId: r.id }, + r.quote as ChainRouteQuote + ) + return [ + r.id, + planScore(measured, { + escrowUsd: 10, + referencePriceUsd: 1, + askedToLend: false, + askedToOfframp: false, + }), + ] + }) + ) + } + + const cetusRoute = (amountOut: string) => ({ + amountIn: { toString: () => '10000000' }, + amountOut: { toString: () => amountOut }, + deviationRatio: '0.001', + paths: [{}], + }) + + it('lets the route delivering more win, when 1Click values its smaller payout higher', async () => { + sui.findRouters.mockResolvedValue(cetusRoute('4950000000')) + suiRace('4900000000', '9.99') + const score = await scores() + expect(score['cetus-1']).toBeGreaterThan(score['1click-sui-1'] as number) + }) + + it('lets 1Click win when it delivers more, though its own figure says otherwise', async () => { + sui.findRouters.mockResolvedValue(cetusRoute('4900000000')) + suiRace('4980000000', '9.60') + const score = await scores() + expect(score['1click-sui-1']).toBeGreaterThan(score['cetus-1'] as number) + }) + + it('still drops a 1Click quote whose own figure fails its fair-value check', async () => { + sui.findRouters.mockResolvedValue(cetusRoute('4950000000')) + suiRace('4950000000', '1') + // 1Click's figure fails its own fair-value check here, so nothing of it can reach a score. + await expect(scores()).resolves.toEqual({ 'cetus-1': expect.any(Number) }) + }) + + describe('rent on Solana', () => { + const SOLANA_LIST = () => [ + { + assetId: 'nep141:sol-usdc', + blockchain: 'sol', + symbol: 'USDC', + decimals: 6, + price: 1, + priceUpdatedAt: updated(), + contractAddress: USDC, + }, + { + assetId: 'nep141:sol-usdt', + blockchain: 'sol', + symbol: 'USDT', + decimals: 6, + price: 1, + priceUpdatedAt: updated(), + contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + }, + ] + + function stubStable(): void { + stubFetch((url, init) => { + if (url.endsWith('/v0/tokens')) return { body: SOLANA_LIST() } + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Record + return { + status: 201, + body: { + quote: { + amountIn: body['amount'], + amountInFormatted: '10', + amountInUsd: '10', + minAmountIn: body['amount'], + amountOut: '10000000', + amountOutFormatted: '10', + amountOutUsd: '10', + minAmountOut: '9950000', + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + }, + } + } + return undefined + }) + } + + const toUsdt = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'USDT' } as const + const solanaChain = (priceSol: number | undefined): ChainModule => ({ + ...solanaModule, + priceUsd: async (t) => (t.symbol === 'SOL' ? priceSol : 1), + venues: [oneClickOriginVenue('solana')], + }) + + it('takes the rent off the value the race sees, at the chain’s SOL price', async () => { + stubStable() + const [route] = await quoteChainRoutes(solanaChain(100), toUsdt, CAPTURE_ACCOUNT) + const q = route?.quote as ChainRouteQuote + expect(q.valueOutUsd).toBeCloseTo(10 - 0.00203928 * 100, 6) + expect(q.receive).toEqual({ amount: '10', symbol: 'USDT' }) + expect(isUnmeasuredSwap(q)).toBe(false) + }) + + it('counts the route as unmeasured when SOL has no price', async () => { + stubStable() + const [route] = await quoteChainRoutes(solanaChain(undefined), toUsdt, CAPTURE_ACCOUNT) + const q = route?.quote as ChainRouteQuote + expect(q.valueOutUsd).toBeUndefined() + expect(isUnmeasuredSwap(q)).toBe(true) + }) + + it('counts a swap the rent would swallow as unmeasured, never as a win', async () => { + stubStable() + const [route] = await quoteChainRoutes(solanaChain(100_000), toUsdt, CAPTURE_ACCOUNT) + const q = route?.quote as ChainRouteQuote + expect(q.valueOutUsd).toBeUndefined() + expect(isUnmeasuredSwap(q)).toBe(true) + }) + + it('leaves a venue with no wallet cost as it was', async () => { + stubFetch(() => undefined) + const venue = { + ...oneClickOriginVenue('solana'), + quote: async () => ({ receive: { amount: '10', symbol: 'USDT' } }), + } + const chain = { ...solanaChain(undefined), venues: [venue] } + const [route] = await quoteChainRoutes(chain, toUsdt, CAPTURE_ACCOUNT) + expect((route?.quote as ChainRouteQuote).valueOutUsd).toBe(10) + }) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index 8c845b61..8ef5ec96 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -664,19 +664,23 @@ describe('lulo', () => { }) describe('module', () => { - it('offers the four venues that need no key', () => { - expect(buildSolanaVenues({}).map((v) => v.id)).toEqual([ + it('adds 1Click once the secret that protects its deposits is there', () => { + expect(buildSolanaVenues({ AUTH_SECRET: 'x'.repeat(48) }).map((v) => v.id)).toEqual([ 'jupiter', '1click-solana', 'jupiter-lend', 'kamino', ]) + }) + + it('offers the three venues that need no key', () => { + expect(buildSolanaVenues({}).map((v) => v.id)).toEqual(['jupiter', 'jupiter-lend', 'kamino']) expect(solanaModule.defaultVenue).toMatchObject({ swap: 'jupiter', supply: 'jupiter-lend' }) expect(solanaModule.chain).toBe('solana') }) it('lets only the swaps land their own transactions', () => { - const [swap, oneClick, lend, kamino] = buildSolanaVenues({}) + const [swap, oneClick, lend, kamino] = buildSolanaVenues({ AUTH_SECRET: 'x'.repeat(48) }) expect(swap?.submit).toBeTypeOf('function') expect(oneClick?.submit).toBeTypeOf('function') expect(lend?.submit).toBeUndefined() diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index a9a8c2d8..42d0e5f0 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -8,7 +8,7 @@ import { submitSui, type SuiExecutor } from '../chains/sui/submit' import { SUI_TOKENS } from '../chains/sui/tokens' import { suiCapabilities } from '../chains/sui/capabilities' import { suiVenues } from '../chains/sui/venues' -import { suiModule } from '../chains/sui/module' +import { buildSuiVenues, suiModule } from '../chains/sui/module' import { tokenBySymbol, ChainActionRefused } from '../chains/types' import { venues } from '../venues' @@ -283,10 +283,18 @@ describe('submitting signed bytes', () => { }) describe('the module', () => { - it('wires only swap, supply and stake, to Cetus, 1Click, NAVI and SpringSui', () => { - expect(suiModule.venues.map((v) => [v.id, v.kinds])).toEqual([ + it('adds 1Click once the secret that protects its deposits is there', () => { + expect(buildSuiVenues({ AUTH_SECRET: 'x'.repeat(48) }).map((v) => v.id)).toEqual([ + 'cetus', + '1click-sui', + 'navi', + 'springsui', + ]) + }) + + it('wires only swap, supply and stake, to Cetus, NAVI and SpringSui', () => { + expect(buildSuiVenues({}).map((v) => [v.id, v.kinds])).toEqual([ ['cetus', ['swap']], - ['1click-sui', ['swap']], ['navi', ['supply']], ['springsui', ['stake']], ]) @@ -318,7 +326,9 @@ describe('the venue list', () => { }) it('claims execution only for what is wired, each with a capability', () => { - const wired = suiModule.venues.map((v) => v.id).sort() + const wired = buildSuiVenues({}) + .map((v) => v.id) + .sort() const claimed = suiVenues.filter((v) => v.integration === 'executes') expect(claimed.map((v) => v.id).sort()).toEqual(wired) for (const venue of claimed) expect(venue.capability).toBeTruthy() diff --git a/apps/dapp/lib/agents/chain-routes.ts b/apps/dapp/lib/agents/chain-routes.ts index 5e1ad1f5..2bc8913c 100644 --- a/apps/dapp/lib/agents/chain-routes.ts +++ b/apps/dapp/lib/agents/chain-routes.ts @@ -63,6 +63,16 @@ export async function quoteChainRoutes( const qty = Number(quoted.receive.amount) if (priceOut !== undefined && Number.isFinite(qty)) valueOutUsd = qty * priceOut } + // A cost that can't be priced leaves what the route is worth unknown, not free of it. + // So does one that swallows the swap: the agents are never shown a negative value. + if (quoted.walletCost !== undefined && valueOutUsd !== undefined) { + const priceCost = await priceOf(quoted.walletCost.symbol) + const net = + priceCost === undefined + ? undefined + : valueOutUsd - Number(quoted.walletCost.amount) * priceCost + valueOutUsd = net !== undefined && net > 0 ? net : undefined + } const quote: ChainRouteQuote = { kind: 'chain', request, diff --git a/apps/dapp/lib/chains/near/swap.ts b/apps/dapp/lib/chains/near/swap.ts index b8f34a0c..6802f5a8 100644 --- a/apps/dapp/lib/chains/near/swap.ts +++ b/apps/dapp/lib/chains/near/swap.ts @@ -212,11 +212,23 @@ function isPositive(amount: string | undefined): amount is string { return amount !== undefined && BigInt(amount) > BigInt(0) } +/** The parts of the cost note that depend on what the chain's wallet pays for itself. */ +export interface CostWording { + withdrawFor: string + total: string +} + +const NEAR_COST_WORDING: CostWording = { + withdrawFor: 'to register you on that token', + total: 'After all costs', +} + export function costNote( envelope: QuoteEnvelope, loss: number, symbol: string, - decimals: number + decimals: number, + wording: CostWording = NEAR_COST_WORDING ): string { const bps = feeBps(envelope) const fee = @@ -225,10 +237,10 @@ export function costNote( : `The quote includes a ${(bps / 100).toFixed(2)}% fee, taken from what you send.` const withdraw = envelope.quote.withdrawFee const registration = isPositive(withdraw) - ? ` It also takes ${formatUnits(withdraw, decimals)} ${symbol} to register you on that token.` + ? ` It also takes ${formatUnits(withdraw, decimals)} ${symbol} ${wording.withdrawFor}.` : '' const percent = (Math.max(loss, 0) * 100).toFixed(2) - return `NEAR Intents runs on mainnet only, so this moves real funds. ${fee}${registration} After all costs you get about ${percent}% less in value than you pay, and at least ${formatUnits(envelope.quote.minAmountOut, decimals)} ${symbol}.` + return `NEAR Intents runs on mainnet only, so this moves real funds. ${fee}${registration} ${wording.total} you get about ${percent}% less in value than you pay, and at least ${formatUnits(envelope.quote.minAmountOut, decimals)} ${symbol}.` } export function depositNote(envelope: QuoteEnvelope, tokenIn: ChainToken, account: string): string { @@ -307,7 +319,7 @@ export function assertNoDepositMemo(envelope: QuoteEnvelope): void { } } -export function venueQuoteFrom(preview: QuoteEnvelope, destination: Destination): VenueQuote { +function venueQuoteFrom(preview: QuoteEnvelope, destination: Destination): VenueQuote { return { receive: { amount: formatUnits(preview.quote.amountOut, destination.decimals), diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts index fef9ab8f..3df6bbbd 100644 --- a/apps/dapp/lib/chains/oneclick-origin.ts +++ b/apps/dapp/lib/chains/oneclick-origin.ts @@ -6,7 +6,7 @@ import { fromBase64, normalizeStructTag } from '@mysten/sui/utils' import { solanaDeposit } from '../weave/deposit/solana' import { suiDeposit } from '../weave/deposit/sui' import type { DepositChain, DepositExpectation } from '../weave/types' -import { depositStatus, listTokens, notifyDeposit } from './near/oneclick' +import { depositStatus, listTokens, notifyDeposit, type OneClickToken } from './near/oneclick' import { assertNoDepositMemo, costNote, @@ -15,7 +15,7 @@ import { liveOneClick, ONECLICK_RISK_NOTE, previewOneClick, - venueQuoteFrom, + type CostWording, type Destination, type QuoteOrigin, } from './near/swap' @@ -68,12 +68,36 @@ const CONFIRM_WAIT_MS = 40_000 const STATUS_WAIT_MS = 50_000 const STATUS_POLL_MS = 2_000 +const COST_WORDING: CostWording = { + withdrawFor: 'to deliver it to you', + total: "After 1Click's costs, and before your wallet's network costs,", +} + +/** + * Rent for a Solana token account of 165 bytes, in SOL. Every swap pays a new deposit address, + * whose token account the transaction opens from this wallet and nobody returns. It is treated + * as always due: a dry quote has no address to look up, and a one-time address has no account + * yet, so a read at Review would only confirm it. If one somehow exists the transaction opens + * nothing and this overstates by the rent, which errs against 1Click, not the person. + */ +const SOLANA_DEPOSIT_RENT_SOL = '0.00203928' + +const SOLANA_RENT_NOTE = + "Your wallet also pays about 0.00204 SOL in rent to open a token account at the deposit address, and you don't get it back. Every swap uses a new address, so this is due each time." + interface Origin { chainName: string family: 'solana' | 'sui' tokens: readonly ChainToken[] - /** The token id that stands for the native coin, which 1Click may list with no contract. */ + /** The token id that stands for the native coin, which 1Click lists with no contract. */ nativeId: string + /** + * Whether that id is the native coin itself. A mint for wrapped SOL is not SOL: paying out to + * it would leave wrapped tokens while the card says SOL. Sui's coin type is SUI itself. + */ + nativeIdIsTheCoin: boolean + /** What the wallet pays beyond the transfer and the fee, with the sentence that says so. */ + walletCost?: { amount: string; symbol: string; note: string } ownSwap: string deposit: DepositChain /** The chain's identifier for signed bytes, known before the network has answered. */ @@ -91,6 +115,8 @@ const ORIGINS: Partial> = { family: 'solana', tokens: SOLANA_TOKENS, nativeId: 'So11111111111111111111111111111111111111112', + nativeIdIsTheCoin: false, + walletCost: { amount: SOLANA_DEPOSIT_RENT_SOL, symbol: 'SOL', note: SOLANA_RENT_NOTE }, ownSwap: 'Jupiter', deposit: solanaDeposit, hashOf: (signed) => @@ -102,6 +128,7 @@ const ORIGINS: Partial> = { family: 'sui', tokens: SUI_TOKENS, nativeId: '0x2::sui::SUI', + nativeIdIsTheCoin: true, ownSwap: 'Cetus', deposit: suiDeposit, hashOf: (signed) => @@ -128,8 +155,9 @@ function normalizeContract(chain: OneClickOriginChain, contract: string): string * anyone can name a token USDC, so a symbol alone never matches. Two entries for one contract * leave no way to say which is real, so that is no match either. * - * A native coin has no contract on its chain, and the list may carry it with none. It matches - * only as the chain's own native token id against the one entry with that symbol and no contract. + * A native coin has no contract, so it matches only as the chain's own native token id, against + * the one entry with its symbol and no contract. A contract that merely wraps it never matches, + * since what would arrive is not what the card says. */ export async function oneClickAssetId( chain: OneClickOriginChain, @@ -139,29 +167,35 @@ export async function oneClickAssetId( (entry) => entry.blockchain === BLOCKCHAIN[chain] && entry.decimals === token.decimals ) const wanted = normalizeContract(chain, token.id) - const matching = entries.filter( - (entry) => - typeof entry.contractAddress === 'string' && - entry.contractAddress !== '' && - normalizeContract(chain, entry.contractAddress) === wanted - ) - if (matching.length === 1) return matching[0]?.assetId - if (matching.length > 1 || token.id !== ORIGINS[chain]?.nativeId) return undefined - const native = entries.filter( - (entry) => - (entry.contractAddress === undefined || - entry.contractAddress === null || - entry.contractAddress === '') && - entry.symbol.toUpperCase() === token.symbol.toUpperCase() - ) - return native.length === 1 ? native[0]?.assetId : undefined + const hasContract = (entry: OneClickToken): boolean => + entry.contractAddress !== undefined && + entry.contractAddress !== null && + entry.contractAddress !== '' + const isContract = (entry: OneClickToken): boolean => + hasContract(entry) && normalizeContract(chain, entry.contractAddress as string) === wanted + + const origin = ORIGINS[chain] + const matching = + token.id === origin?.nativeId + ? entries.filter( + (entry) => + entry.symbol.toUpperCase() === token.symbol.toUpperCase() && + (!hasContract(entry) || (origin.nativeIdIsTheCoin && isContract(entry))) + ) + : entries.filter(isContract) + return matching.length === 1 ? matching[0]?.assetId : undefined } -function secret(): string | undefined { - const value = process.env['AUTH_SECRET'] +function secret(env: Record = process.env): string | undefined { + const value = env['AUTH_SECRET'] return value !== undefined && value.length >= 32 ? value : undefined } +/** Whether a chain should offer this venue at all. Nothing can be issued safely without it. */ +export function canIssueOneClick(env: Record): boolean { + return secret(env) !== undefined +} + /** Without the secret nothing could be issued safely at Review, so no route is offered at all. */ function signingKey(): string { const key = secret() @@ -381,7 +415,17 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { route.destination, account ) - return venueQuoteFrom(preview, route.destination) + // Only what arrives, not 1Click's own dollar figure. The other routes on this chain are + // valued by the chain's price source, and a gap between two sources could outweigh a gap + // between two routes, letting the one that delivers less win. + const cost = origin.walletCost + return { + receive: { + amount: formatUnits(preview.quote.amountOut, route.destination.decimals), + symbol: route.destination.symbol, + }, + ...(cost !== undefined ? { walletCost: { amount: cost.amount, symbol: cost.symbol } } : {}), + } } async function prepare(request: ChainActionRequest, account: string): Promise { @@ -425,7 +469,8 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { approximate: true, }, notes: [ - costNote(live, loss, destination.symbol, destination.decimals), + costNote(live, loss, destination.symbol, destination.decimals, COST_WORDING), + ...(origin.walletCost !== undefined ? [origin.walletCost.note] : []), depositNote(live, tokenIn, account), ONECLICK_RISK_NOTE, ], diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index a26bd4c0..52fb42ce 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -40,6 +40,24 @@ export function afterSigning(outcome: SignedOutcome, family: Family): AfterSigni return step } +/** + * Hands signed bytes to the server. A dropped connection or an answer that can't be read, such + * as the platform's own page when it stops the function, says nothing about whether they went + * out, so it comes back as no answer, which `afterSubmitting` reads as pending. + */ +export async function submitSigned(url: string, body: unknown): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return (await res.json()) as unknown + } catch { + return undefined + } +} + export type AfterSubmitting = | { phase: 'settled'; hash: string; explorerUrl: string | undefined; latched: true } | { phase: 'pending'; error: string; latched: true } diff --git a/apps/dapp/lib/chains/solana/jupiter-swap.ts b/apps/dapp/lib/chains/solana/jupiter-swap.ts index f615c3a0..eed0469f 100644 --- a/apps/dapp/lib/chains/solana/jupiter-swap.ts +++ b/apps/dapp/lib/chains/solana/jupiter-swap.ts @@ -30,8 +30,11 @@ const REFERRAL_FEE_BPS = 50 * and OKX land through programs not confirmable from their docs. See programs.ts. */ const EXCLUDED_ROUTERS = 'jupiterz,dflow,okx' -/** `/execute` waits for the transaction to land, so it gets longer than other calls. */ -const EXECUTE_TIMEOUT_MS = 70_000 +/** + * `/execute` waits for the transaction to land, so it gets longer than other calls, but stops + * short of the submit route's 60 seconds so that an answer can still be given. + */ +const EXECUTE_TIMEOUT_MS = 50_000 const NEED_SOL_FOR_FEES = 'This wallet needs at least 0.01 SOL to pay its own network fees before I can swap. Add a little SOL and try again.' @@ -291,21 +294,28 @@ export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue } } const { signedTransaction } = input.signed - if (feePayerSignature(signedTransaction) === undefined) { + const sentSignature = feePayerSignature(signedTransaction) + if (sentSignature === undefined) { return { ok: false, error: 'Your wallet returned this unsigned, so nothing was sent. Sign it again.', } } - const response = await fetch(`${JUPITER_API}/swap/v2/execute`, { - method: 'POST', - headers: { 'content-type': 'application/json', ...jupiterHeaders(env) }, - body: JSON.stringify({ signedTransaction, requestId }), - signal: AbortSignal.timeout(EXECUTE_TIMEOUT_MS), - }) - // A server failure or timeout leaves the outcome unknown. Throwing sends it to the - // route, which tells the user to check their wallet before trying again. + let response: Response + try { + response = await fetch(`${JUPITER_API}/swap/v2/execute`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...jupiterHeaders(env) }, + body: JSON.stringify({ signedTransaction, requestId }), + signal: AbortSignal.timeout(EXECUTE_TIMEOUT_MS), + }) + } catch { + // A timeout or dropped connection may have come after Jupiter broadcast it. + return unconfirmedSwap(sentSignature) + } + // A server failure leaves the outcome unknown. Throwing sends it to the route, which + // tells the user to check their wallet before trying again. if (response.status >= 500 || response.status === 429) { throw new Error(`Jupiter /execute answered ${response.status}`) } diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts index cda015cc..e8fff1ac 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -1,4 +1,4 @@ -import { oneClickOriginVenue } from '../oneclick-origin' +import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' import type { ChainModule, ChainVenue } from '../types' import { createJupiterLendVenue } from './jupiter-lend' import { createJupiterSwapVenue } from './jupiter-swap' @@ -10,13 +10,16 @@ import { SOLANA_TOKENS } from './tokens' const priceUsd = createPriceSource() +/** Inside the chain submit route's 60 seconds, leaving room to answer. Weave's route has its own. */ +const SUBMIT_DEADLINE_MS = 50_000 + export function buildSolanaVenues( env: Record = process.env ): ChainVenue[] { const luloKey = env['LULO_API_KEY']?.trim() return [ createJupiterSwapVenue({ priceUsd, env }), - oneClickOriginVenue('solana'), + ...(canIssueOneClick(env) ? [oneClickOriginVenue('solana')] : []), createJupiterLendVenue({ env }), createKaminoVenue(), ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []), @@ -29,5 +32,5 @@ export const solanaModule: ChainModule = { venues: buildSolanaVenues(), defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend' }, priceUsd, - submit: createSolanaSubmit(), + submit: createSolanaSubmit({ deadlineMs: SUBMIT_DEADLINE_MS }), } diff --git a/apps/dapp/lib/chains/solana/submit.ts b/apps/dapp/lib/chains/solana/submit.ts index 11c29e3f..84be4074 100644 --- a/apps/dapp/lib/chains/solana/submit.ts +++ b/apps/dapp/lib/chains/solana/submit.ts @@ -91,6 +91,11 @@ export interface SolanaSubmitOptions { env?: Record pollMs?: number maxPolls?: number + /** + * How long a submit may wait in all, for a route the platform would otherwise stop part way + * with no answer. Time up means pending, because the transaction may still land. + */ + deadlineMs?: number } /** @@ -102,14 +107,19 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { const env = options.env ?? process.env const pollMs = options.pollMs ?? POLL_MS const maxPolls = options.maxPolls ?? MAX_POLLS + const { deadlineMs } = options - async function rpc(method: string, params: unknown[]): Promise> { + async function rpc( + method: string, + params: unknown[], + timeoutMs = RPC_TIMEOUT_MS + ): Promise> { const url = env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(RPC_TIMEOUT_MS), + signal: AbortSignal.timeout(timeoutMs), }) // A node answers JSON-RPC errors with a 200. Any other status came from something // in between, so its body can't prove what the node did. @@ -118,9 +128,13 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { } /** A status, `null` if the node answered and hasn't seen it, `undefined` if it didn't answer. */ - async function readStatus(hash: string, config?: object): Promise { + async function readStatus( + hash: string, + timeoutMs: number, + config?: object + ): Promise { const params = config !== undefined ? [[hash], config] : [[hash]] - const reply = await rpc('getSignatureStatuses', params).catch(() => undefined) + const reply = await rpc('getSignatureStatuses', params, timeoutMs).catch(() => undefined) const statuses = Statuses.safeParse(reply?.result) return statuses.success ? statuses.data.value[0] : undefined } @@ -138,6 +152,15 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { } } + const startedAt = Date.now() + const timeLeft = (): number => + deadlineMs === undefined ? Number.POSITIVE_INFINITY : startedAt + deadlineMs - Date.now() + const notConfirmed: SubmitResult = { + ok: false, + pending: true, + error: `This was sent, but not confirmed yet. Check ${explorerUrl(hash)} before trying again, in case it goes through.`, + } + const sent = await rpc('sendTransaction', [ signedTransaction, { encoding: 'base64', preflightCommitment: 'confirmed' }, @@ -156,14 +179,16 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { // saw it means it reached a block. Either way, later silence no longer proves anything. let mayHaveLanded = false for (let poll = 0; poll < maxPolls; poll += 1) { + if (timeLeft() <= 0) return notConfirmed await new Promise((resolve) => setTimeout(resolve, pollMs)) - const status = await readStatus(hash) + const status = await readStatus(hash, Math.max(1, Math.min(RPC_TIMEOUT_MS, timeLeft()))) if (status === null) continue mayHaveLanded = true const settled = status !== undefined ? settledBy(status, hash) : undefined if (settled !== undefined) return settled } + if (timeLeft() <= 0) return notConfirmed const { blockhash } = decodeSolanaTransaction(signedTransaction) const validity = await rpc('isBlockhashValid', [blockhash, { commitment: 'confirmed' }]).catch( () => undefined @@ -172,15 +197,11 @@ export function createSolanaSubmit(options: SolanaSubmitOptions = {}) { if (valid.success && !valid.data.value) { // An expired blockhash only rules out the future, so this read comes after it, to // catch a transaction that landed before the blockhash ran out. - const last = await readStatus(hash, { searchTransactionHistory: true }) + const last = await readStatus(hash, RPC_TIMEOUT_MS, { searchTransactionHistory: true }) if (last === null && !mayHaveLanded) return { ok: false, error: EXPIRED } const settled = last !== null && last !== undefined ? settledBy(last, hash) : undefined if (settled !== undefined) return settled } - return { - ok: false, - pending: true, - error: `This was sent, but not confirmed yet. Check ${explorerUrl(hash)} before trying again, in case it goes through.`, - } + return notConfirmed } } diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts index 4f87c9be..82a45394 100644 --- a/apps/dapp/lib/chains/solana/venues.ts +++ b/apps/dapp/lib/chains/solana/venues.ts @@ -19,6 +19,7 @@ export const solanaVenues: Venue[] = [ 'Swaps USDC, USDT, SOL, JitoSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.', }, { + // Listed, as Lulo is: whether this runs depends on a secret this client-safe file can't read. id: '1click-solana', family: 'solana', name: 'NEAR Intents 1Click', @@ -26,9 +27,6 @@ export const solanaVenues: Venue[] = [ chains: ['Solana'], bestFor: 'A second swap price from competing market makers, spending USDC or USDT', url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', - integration: 'executes', - capability: - 'Swaps USDC or USDT for another token in your Solana wallet that 1Click also lists. Your wallet signs one transfer to a one-time deposit address. Mainnet only. No swap has been signed through this app yet.', }, { id: 'jupiter-lend', diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts index 01d88086..a1d692f1 100644 --- a/apps/dapp/lib/chains/sui/module.ts +++ b/apps/dapp/lib/chains/sui/module.ts @@ -1,5 +1,5 @@ -import { oneClickOriginVenue } from '../oneclick-origin' -import type { ChainModule } from '../types' +import { canIssueOneClick, oneClickOriginVenue } from '../oneclick-origin' +import type { ChainModule, ChainVenue } from '../types' import { cetusVenue } from './cetus' import { naviVenue } from './navi' import { priceFromLlama } from './price' @@ -7,10 +7,21 @@ import { springsuiVenue } from './springsui' import { submitSui } from './submit' import { SUI_TOKENS } from './tokens' +export function buildSuiVenues( + env: Record = process.env +): ChainVenue[] { + return [ + cetusVenue, + ...(canIssueOneClick(env) ? [oneClickOriginVenue('sui')] : []), + naviVenue, + springsuiVenue, + ] +} + export const suiModule: ChainModule = { chain: 'sui', tokens: SUI_TOKENS, - venues: [cetusVenue, oneClickOriginVenue('sui'), naviVenue, springsuiVenue], + venues: buildSuiVenues(), defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui' }, priceUsd: (token) => priceFromLlama(token), submit: (input) => submitSui(input), diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts index 2ea306eb..0982fc41 100644 --- a/apps/dapp/lib/chains/sui/venues.ts +++ b/apps/dapp/lib/chains/sui/venues.ts @@ -29,18 +29,14 @@ export const suiVenues: Venue[] = [ integration: 'executes', capability: 'Swaps SUI, USDC, USDsui and USDT', }, - { - id: '1click-sui', - family: 'sui', - name: 'NEAR Intents 1Click', - category: 'swap', - chains: ['Sui'], - url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', - bestFor: 'A second swap price from competing market makers, spending USDC', - integration: 'executes', - capability: - 'Swaps USDC for SUI in your Sui wallet, the pair 1Click lists here. Your wallet signs one transfer to a one-time deposit address. Mainnet only. No swap has been signed through this app yet.', - }, + // Listed, as Lulo is on Solana: whether this runs depends on a secret this client-safe file can't read. + listed( + '1click-sui', + 'NEAR Intents 1Click', + 'swap', + 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api', + 'A second swap price from competing market makers, spending USDC or USDT' + ), { id: 'navi', family: 'sui', diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index a5d0b308..31bb9f6c 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -100,7 +100,13 @@ export class ChainActionRefused extends Error {} export interface VenueQuote { receive?: { amount: string; symbol: string } apyPercent?: number + /** Only where every route on the chain is valued by the same source, as on NEAR. */ valueOutUsd?: number + /** + * What this route costs the wallet beyond what it delivers, as a route elsewhere would not. + * The race takes it off the route's value, at the chain's own price for this token. + */ + walletCost?: { amount: string; symbol: string } } export interface ChainVenue { From 2bf47ab1d778a03ff75dd53b1d50d9f7f1f44c35 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:51:49 +0100 Subject: [PATCH 14/24] feat(dapp): swap and race on avalanche and monad through 1click Avalanche and Monad get their first swaps, and join the agent race, with 1Click as the one route. AVAX and MON can be spent here, so an EVM payload's value is now decimal base units. Weave's Avalanche deposit still asserts zero and now refuses any token but USDC. The wallet sends for itself, so the proof is checked at prepare and submit asks the chain: the receipt, the value for a native transfer, and the Transfer log for a token. Only this account's revert, to the expected address, is clean. Anything else after a signature is pending, including an expired quote or a proof that doesn't hold, since neither can show nothing was sent. Balances are read before the real quote, so someone who can't pay never gets a deposit address, and native input keeps gas back. Monad says USDT0 by name, and someone typing USDT is told so. --- apps/dapp/.env.example | 4 +- apps/dapp/app/api/agents/compete/route.ts | 2 +- .../app/api/chain/[chain]/submit/route.ts | 11 +- apps/dapp/components/apps/venue-grid.tsx | 6 +- .../intents/chain-action-confirm.tsx | 2 +- apps/dapp/components/intents/intent-chat.tsx | 2 +- apps/dapp/hooks/use-chain-action.ts | 6 + .../lib/__tests__/compete-route-log.test.ts | 3 +- apps/dapp/lib/__tests__/evm-module.test.ts | 1453 +++++++++++++++++ .../lib/__tests__/market-context-evm.test.ts | 32 + .../lib/__tests__/oneclick-origin.test.ts | 25 +- .../__tests__/weave-deposit-avalanche.test.ts | 27 +- apps/dapp/lib/adapters/evm-adapter.ts | 82 +- apps/dapp/lib/agents/market-context.ts | 21 +- apps/dapp/lib/chains/capabilities.ts | 3 + apps/dapp/lib/chains/evm/capabilities.ts | 10 + apps/dapp/lib/chains/evm/client.ts | 49 + apps/dapp/lib/chains/evm/deposit.ts | 198 +++ apps/dapp/lib/chains/evm/module.ts | 52 + apps/dapp/lib/chains/evm/submit.ts | 98 ++ apps/dapp/lib/chains/evm/tokens.ts | 89 + apps/dapp/lib/chains/evm/venues.ts | 26 + apps/dapp/lib/chains/oneclick-origin.ts | 120 +- apps/dapp/lib/chains/prepare.ts | 8 +- apps/dapp/lib/chains/registry.ts | 3 + apps/dapp/lib/chains/signing-flow.ts | 20 +- apps/dapp/lib/chains/types.ts | 13 +- apps/dapp/lib/orchestrate/capabilities.ts | 2 +- apps/dapp/lib/server/analytics.ts | 2 + apps/dapp/lib/venues.ts | 22 +- apps/dapp/lib/weave/deposit/avalanche.ts | 31 +- apps/dapp/lib/weave/types.ts | 11 +- .../images/venues/1click-avalanche.webp | Bin 0 -> 1234 bytes .../public/images/venues/1click-monad.webp | Bin 0 -> 1234 bytes 34 files changed, 2311 insertions(+), 122 deletions(-) create mode 100644 apps/dapp/lib/__tests__/evm-module.test.ts create mode 100644 apps/dapp/lib/__tests__/market-context-evm.test.ts create mode 100644 apps/dapp/lib/chains/evm/capabilities.ts create mode 100644 apps/dapp/lib/chains/evm/client.ts create mode 100644 apps/dapp/lib/chains/evm/deposit.ts create mode 100644 apps/dapp/lib/chains/evm/module.ts create mode 100644 apps/dapp/lib/chains/evm/submit.ts create mode 100644 apps/dapp/lib/chains/evm/tokens.ts create mode 100644 apps/dapp/lib/chains/evm/venues.ts create mode 100644 apps/dapp/public/images/venues/1click-avalanche.webp create mode 100644 apps/dapp/public/images/venues/1click-monad.webp diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 4a7403ca..c1dca7ca 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -210,5 +210,7 @@ WEAVE_SECRET_KEY= WEAVE_WEBHOOK_SECRET= # Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others. WEAVE_CASHOUT_CURRENCIES= -# Avalanche C-Chain RPC used to build and confirm USDC deposits. +# Avalanche C-Chain RPC used to build and confirm USDC deposits, and to confirm 1Click swaps there. AVALANCHE_RPC_URL= +# Monad RPC used to read balances and confirm 1Click swaps. Unset, viem's default public endpoint is used. +MONAD_RPC_URL= diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 3a060fa2..c1e73877 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -322,7 +322,7 @@ export async function POST(request: Request): Promise { if (!canExecuteOn(chain)) { return errorStream( 'chain_unsupported', - 'Agents can execute only on Stellar, Solana, Sui and NEAR right now. ' + + 'Agents can execute only on Stellar, Solana, Sui, NEAR, Avalanche and Monad right now. ' + 'Switch the chain to run a competition.' ) } diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index e042dc9d..5d5e868d 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -1,3 +1,4 @@ +import { CHAIN_DESCRIPTORS } from '@intent/config' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -24,6 +25,8 @@ const Signed = z.discriminatedUnion('family', [ family: z.literal('near'), hashes: z.array(z.string().min(1).max(128)).min(1).max(8), }), + // The venue checks its shape, so an odd answer from a wallet that may have sent is read as pending. + z.object({ family: z.literal('evm'), hash: z.string().max(128) }), ]) const Body = z.object({ @@ -56,12 +59,14 @@ export async function POST( const venue = chainApi.venues.find((v) => v.id === venueId) if (venue === undefined) return NextResponse.json({ error: 'unknown_venue' }, { status: 404 }) - if (signed.family !== chainApi.chain) { + // By family, since Avalanche and Monad are both EVM. + if (signed.family !== CHAIN_DESCRIPTORS[chainApi.chain].family) { return NextResponse.json({ error: 'wrong_chain' }, { status: 400 }) } - // A NEAR wallet has already sent its transactions, so there are no bytes left to check. - if (signed.family !== 'near') { + // NEAR and EVM wallets have already sent, so there are no bytes left to check. Their venues + // ask the chain what was sent instead. + if (signed.family !== 'near' && signed.family !== 'evm') { const payload: SigningPayload = signed.family === 'solana' ? { family: 'solana', transaction: signed.signedTransaction } diff --git a/apps/dapp/components/apps/venue-grid.tsx b/apps/dapp/components/apps/venue-grid.tsx index 85f0b126..2b5a9a1c 100644 --- a/apps/dapp/components/apps/venue-grid.tsx +++ b/apps/dapp/components/apps/venue-grid.tsx @@ -6,7 +6,7 @@ import { Search, X } from 'lucide-react' import { useMemo, useState } from 'react' import { useChain } from '../../providers/chain-provider' -import { isListedOn, notOnNetworkLabel, venueDetail, venues } from '../../lib/venues' +import { isListedOn, notOnNetworkLabel, venueDetail, venueOnChain, venues } from '../../lib/venues' import { categoryLabel, VenueCard, VenueLogo, VerifiedTick } from './venue-card' /** Integrated first, then quoted, then merely listed. */ @@ -42,7 +42,7 @@ export function VenueGrid({ const rows = useMemo( () => venues - .filter((v) => v.family === descriptor.family && isListedOn(v)) + .filter((v) => venueOnChain(v, descriptor) && isListedOn(v)) // Venues the app can actually use come first. Alphabetical order put // Aquarius — then only priced, never signed — above Soroswap, which // intents routed through, so the most useful thing was buried. @@ -55,7 +55,7 @@ export function VenueGrid({ live: notHere === undefined && (venue.integration ?? 'listed') !== 'listed', } }), - [descriptor.family, availableIds] + [descriptor, availableIds] ) const categories = useMemo(() => { diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 3f8b6088..24229157 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -5,7 +5,7 @@ import { ExternalLink, Loader2 } from 'lucide-react' import type { ChainAction } from '../../hooks/use-chain-action' -/** Review, sign and result for an action on Solana, Sui or NEAR. */ +/** Review, sign and result for an action on a chain other than Stellar. */ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element { const { phase, prepared, error, hash, explorerUrl } = action const busy = phase === 'signing' || phase === 'submitting' diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 282dfa2c..b91a61e4 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -1570,7 +1570,7 @@ export function IntentChat(): JSX.Element { cashout.dismiss() if (chainCapabilities(slug) === undefined) chainAction.reset() - // Solana, Sui and NEAR have their own path: the race and the swap routes + // Chains with their own module have their own path: the race and the swap routes // below are Stellar's. Anything that isn't an instruction, such as a // question, still goes to the orchestrator. const capabilities = chainCapabilities(slug) diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index fb802a10..ac43f139 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -8,6 +8,8 @@ import { afterSigning, afterSubmitting, latchBeforeSign, + QUOTE_EXPIRED, + quoteExpiredBeforeSigning, submitSigned, } from '../lib/chains/signing-flow' import { useChain } from '../providers/chain-provider' @@ -146,6 +148,10 @@ export function useChainAction(): ChainAction { async function run(prepared: PreparedAction): Promise { // Checked again immediately before the wallet opens. if (wrongWallet()) return + if (quoteExpiredBeforeSigning(prepared)) { + setState((s) => ({ ...s, phase: 'failed', error: QUOTE_EXPIRED })) + return + } const family = prepared.signing.family // A wallet that sends for itself may send and then report an error, so // the latch is set before it is called, not after it answers. diff --git a/apps/dapp/lib/__tests__/compete-route-log.test.ts b/apps/dapp/lib/__tests__/compete-route-log.test.ts index 4ed6a9f4..25581f52 100644 --- a/apps/dapp/lib/__tests__/compete-route-log.test.ts +++ b/apps/dapp/lib/__tests__/compete-route-log.test.ts @@ -123,7 +123,8 @@ describe('POST /api/agents/compete', () => { error: 'aborted: timeout', }) for (const a of attempts) expect(a.latencyMs).toBeGreaterThanOrEqual(0) - }) + // The first import of the route pulls in every chain's SDK, which sits right at the default. + }, 30_000) it('records a race that nobody answered, with no winner', async () => { state.roster = [ diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts new file mode 100644 index 00000000..8335d837 --- /dev/null +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -0,0 +1,1453 @@ +import { custom, encodeFunctionData, parseAbi, type Address, type TransactionReceipt } from 'viem' +import { avalanche, monad } from 'viem/chains' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { singleRouteNote } from '../agents/chain-race' +import { quoteChainRoutes } from '../agents/chain-routes' +import { chainCapabilities } from '../chains/capabilities' +import { createEvmDeposit } from '../chains/evm/deposit' +import { buildEvmVenues } from '../chains/evm/module' +import { AVALANCHE_TOKENS, EVM_CHAINS, EVM_NATIVE_ID, MONAD_TOKENS } from '../chains/evm/tokens' +import { clearTokenCache } from '../chains/near/oneclick' +import { oneClickAssetId, oneClickOriginVenue } from '../chains/oneclick-origin' +import { parseChainAction } from '../chains/parse-action' +import { assertKnownTokens, prepareChainAction } from '../chains/prepare' +import { chainModule } from '../chains/registry' +import { afterSubmitting, quoteExpiredBeforeSigning } from '../chains/signing-flow' +import { + ChainActionRefused, + tokenBySymbol, + type ChainActionRequest, + type ChainToken, + type PreparedAction, + type SigningPayload, + type SubmitResult, +} from '../chains/types' +import { buildMarketContext } from '../agents/market-context' +import { capabilitySheet } from '../orchestrate/capabilities' +import { venueOnChain, venues } from '../venues' +import { CHAIN_DESCRIPTORS } from '@intent/config' + +vi.hoisted(() => { + process.env['AUTH_SECRET'] = 'x'.repeat(48) +}) + +vi.mock('../chains/near/quote-signature', () => ({ + verifyQuoteSignature: vi.fn(() => true), +})) +vi.mock('../server/analytics', () => ({ logExecution: async () => undefined })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ reportError: () => undefined })) + +const rpc = vi.hoisted(() => ({ + tokenBalance: (async () => BigInt(0)) as (token: string, account: string) => Promise, + nativeBalance: (async () => BigInt(0)) as (account: string) => Promise, + waitForReceipt: (async () => { + throw new Error('no receipt') + }) as (hash: string) => Promise, + transactionValue: (async () => BigInt(0)) as (hash: string) => Promise, + reads: [] as string[], +})) + +// The venue reads through this, so a test decides what the chain says. A test that passes a +// transport gets the real client over it. +vi.mock('../chains/evm/client', async (original) => { + const actual = await original() + const fake = { + tokenBalance: (token: string, account: string) => rpc.tokenBalance(token, account), + nativeBalance: (account: string) => rpc.nativeBalance(account), + waitForReceipt: (hash: string) => { + rpc.reads.push(hash) + return rpc.waitForReceipt(hash) + }, + transactionValue: (hash: string) => rpc.transactionValue(hash), + } + return { + ...actual, + evmClient: (chain: Parameters[0], transport?: never) => + transport === undefined ? fake : actual.evmClient(chain, transport), + realEvmClient: actual.evmClient, + } +}) + +const wallet = vi.hoisted(() => ({ + account: { status: 'connected', chainId: 143 } as { status: string; chainId: number | undefined }, + calls: [] as unknown[][], +})) + +vi.mock('wagmi/actions', () => ({ + getAccount: () => wallet.account, + switchChain: async (_config: unknown, args: unknown) => { + wallet.calls.push(['switch', args]) + return { id: 143 } + }, + sendTransaction: async (_config: unknown, args: unknown) => { + wallet.calls.push(['send', args]) + return WALLET_HASH + }, +})) +vi.mock('../wagmi.config', () => ({ arcTestnet: { id: 5042002 }, wagmiConfig: {} })) + +const WALLET_HASH = `0x${'c'.repeat(64)}` +const ACCOUNT = '0x1111111111111111111111111111111111111111' +const DEPOSIT = '0x2222222222222222222222222222222222222222' +const OTHER = '0x3333333333333333333333333333333333333333' +const ZERO = '0x0000000000000000000000000000000000000000' +const HASH = `0x${'a'.repeat(64)}` +const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' +const TRANSFER_ABI = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']) + +const token = (list: readonly ChainToken[], symbol: string): ChainToken => + tokenBySymbol(list, symbol) as ChainToken + +const AVAX = token(AVALANCHE_TOKENS, 'AVAX') +const AVAX_USDC = token(AVALANCHE_TOKENS, 'USDC') +const AVAX_USDT = token(AVALANCHE_TOKENS, 'USDT') +const MON = token(MONAD_TOKENS, 'MON') +const MONAD_USDC = token(MONAD_TOKENS, 'USDC') +const USDT0 = token(MONAD_TOKENS, 'USDT0') + +const USD_UNITS = BigInt(25_000_000) +const ONE_AND_A_HALF_AVAX = '1500000000000000000' + +type Json = Record + +interface Priced { + decimals: number + price: number +} + +const ASSETS: Record = { + 'nep245:avax-native': { decimals: 18, price: 20 }, + 'nep245:avax-usdc': { decimals: 6, price: 1 }, + 'nep245:avax-usdt': { decimals: 6, price: 1 }, + 'nep245:monad-native': { decimals: 18, price: 0.04 }, + 'nep245:monad-usdc': { decimals: 6, price: 1 }, + 'nep245:monad-usdt0': { decimals: 6, price: 1 }, +} + +function tokenList(): Json[] { + const priceUpdatedAt = new Date().toISOString() + const entry = ( + assetId: string, + blockchain: string, + symbol: string, + contractAddress?: string + ): Json => ({ + assetId, + blockchain, + symbol, + decimals: ASSETS[assetId]?.decimals ?? 6, + price: ASSETS[assetId]?.price ?? 1, + priceUpdatedAt, + ...(contractAddress !== undefined ? { contractAddress } : {}), + }) + return [ + entry('nep245:avax-native', 'avax', 'AVAX'), + // 1Click writes these in lower case, and the constants carry a checksum. + entry('nep245:avax-usdc', 'avax', 'USDC', AVAX_USDC.id.toLowerCase()), + entry('nep245:avax-usdt', 'avax', 'USDT', AVAX_USDT.id.toLowerCase()), + entry('nep245:avax-fake', 'avax', 'USDC', OTHER), + entry('nep245:monad-native', 'monad', 'MON'), + entry('nep245:monad-usdc', 'monad', 'USDC', MONAD_USDC.id.toLowerCase()), + entry('nep245:monad-usdt0', 'monad', 'USDT0', USDT0.id.toLowerCase()), + ] +} + +interface Sim { + tokens: Json[] + quotes: Json[] + notified: Json[] + statuses: (string | undefined)[] + statusRecipient: string | undefined + depositAddress: string +} + +let sim: Sim + +function goodQuote(body: Json): Json { + const amount = body['amount'] as string + const from = ASSETS[body['originAsset'] as string] as Priced + const to = ASSETS[body['destinationAsset'] as string] as Priced + const micro = (price: number): bigint => BigInt(Math.round(price * 1_000_000)) + const out = + (BigInt(amount) * micro(from.price) * BigInt(10) ** BigInt(to.decimals)) / + (micro(to.price) * BigInt(10) ** BigInt(from.decimals)) + const usd = (Number(amount) / 10 ** from.decimals) * from.price + return { + quote: { + amountIn: amount, + amountInFormatted: 'x', + amountInUsd: String(usd), + minAmountIn: amount, + amountOut: out.toString(), + amountOutFormatted: 'x', + amountOutUsd: String(usd * 0.995), + minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(), + ...(body['dry'] === true ? {} : { depositAddress: sim.depositAddress }), + }, + quoteRequest: body, + signature: 'ed25519:x', + timestamp: '2026-11-04T00:00:00.000Z', + } +} + +function installFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const json = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status }) + if (url.endsWith('/v0/tokens')) return json(sim.tokens) + if (url.endsWith('/v0/quote')) { + const body = JSON.parse(init?.body as string) as Json + sim.quotes.push(body) + return json(goodQuote(body), 201) + } + if (url.endsWith('/v0/deposit/submit')) { + sim.notified.push(JSON.parse(init?.body as string) as Json) + return json({ correlationId: 'c' }) + } + if (url.includes('/v0/status')) { + const status = sim.statuses.length > 1 ? sim.statuses.shift() : sim.statuses[0] + return json({ status, quoteResponse: { quoteRequest: { recipient: sim.statusRecipient } } }) + } + throw new Error(`no live call: ${url}`) + }) + ) +} + +const word = (hexDigits: string): string => hexDigits.padStart(64, '0') +const addressWord = (address: string): string => word(address.slice(2).toLowerCase()) + +function transferLog( + tokenAddress: string, + units: bigint, + change: { from?: string; to?: string } = {} +): TransactionReceipt['logs'][number] { + return { + address: tokenAddress, + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: BigInt(1), + data: `0x${word(units.toString(16))}`, + logIndex: 0, + removed: false, + topics: [ + TRANSFER_TOPIC, + `0x${addressWord(change.from ?? ACCOUNT)}`, + `0x${addressWord(change.to ?? DEPOSIT)}`, + ], + transactionHash: HASH, + transactionIndex: 0, + } as unknown as TransactionReceipt['logs'][number] +} + +function receipt(change: Json = {}): Json { + return { status: 'success', from: ACCOUNT, to: DEPOSIT, logs: [], ...change } +} + +const swap = (over: Partial = {}): ChainActionRequest => ({ + kind: 'swap', + amount: '25', + tokenIn: 'USDC', + tokenOut: 'AVAX', + ...over, +}) + +const avaxSwap = (over: Partial = {}): ChainActionRequest => + swap({ tokenIn: 'AVAX', tokenOut: 'USDC', amount: '1.5', ...over }) + +const venueAvalanche = oneClickOriginVenue('avalanche') +const venueMonad = oneClickOriginVenue('monad') + +function submitTo( + prepared: PreparedAction, + venue = venueAvalanche, + change: { hash?: string; account?: string; context?: Record } = {} +): Promise { + return (venue.submit as NonNullable)({ + account: change.account ?? ACCOUNT, + venueId: venue.id, + signed: { family: 'evm', hash: change.hash ?? HASH }, + context: change.context ?? (prepared.context as Record), + }) +} + +function withoutPending(result: SubmitResult): void { + expect(result).toMatchObject({ ok: false }) + expect(result).not.toHaveProperty('pending') +} + +function isPending(result: SubmitResult): void { + expect(result).toMatchObject({ ok: false, pending: true }) +} + +beforeEach(() => { + clearTokenCache() + sim = { + tokens: tokenList(), + quotes: [], + notified: [], + statuses: ['SUCCESS'], + statusRecipient: ACCOUNT, + depositAddress: DEPOSIT, + } + rpc.tokenBalance = async () => BigInt(10) ** BigInt(30) + rpc.nativeBalance = async () => BigInt(10) ** BigInt(30) + rpc.waitForReceipt = async () => { + throw new Error('no receipt was arranged for this test') + } + rpc.transactionValue = async () => { + throw new Error('no transaction was arranged for this test') + } + rpc.reads = [] + installFetch() +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + wallet.account = { status: 'connected', chainId: 143 } + wallet.calls = [] +}) + +describe('the chain modules', () => { + it('confirms what viem says the chain ids are', () => { + expect(monad.id).toBe(143) + expect(avalanche.id).toBe(43114) + expect(EVM_CHAINS.monad.chain.id).toBe(143) + expect(EVM_CHAINS.avalanche.chain.id).toBe(43114) + }) + + it('finds a module for both, each with its own venue only', () => { + const avalancheModule = chainModule('avalanche') + const monadModule = chainModule('monad') + expect(avalancheModule?.chain).toBe('avalanche') + expect(monadModule?.chain).toBe('monad') + expect(avalancheModule?.venues.map((v) => v.id)).toEqual(['1click-avalanche']) + expect(monadModule?.venues.map((v) => v.id)).toEqual(['1click-monad']) + expect(avalancheModule?.defaultVenue.swap).toBe('1click-avalanche') + expect(monadModule?.defaultVenue.swap).toBe('1click-monad') + expect(chainModule('arc')).toBeUndefined() + }) + + it('lists the tokens the brief names, with the contracts 1Click lists', () => { + expect(AVALANCHE_TOKENS.map((t) => t.symbol)).toEqual(['AVAX', 'USDC', 'USDT']) + expect(MONAD_TOKENS.map((t) => t.symbol)).toEqual(['MON', 'USDC', 'USDT0']) + expect(AVAX_USDC.id.toLowerCase().startsWith('0xb97ef9ef8734')).toBe(true) + expect(MONAD_USDC.id.toLowerCase().startsWith('0x754704bc059f')).toBe(true) + expect(AVAX.id).toBe(EVM_NATIVE_ID) + expect(MON.id).toBe(EVM_NATIVE_ID) + expect(AVAX.decimals).toBe(18) + expect(MONAD_USDC.decimals).toBe(6) + }) + + it('offers no venue without the secret that protects what would be issued', () => { + expect(buildEvmVenues('avalanche', {})).toEqual([]) + expect(buildEvmVenues('monad', { AUTH_SECRET: 'short' })).toEqual([]) + expect(buildEvmVenues('monad', { AUTH_SECRET: 'x'.repeat(32) }).map((v) => v.id)).toEqual([ + '1click-monad', + ]) + }) + + it('tells someone typing USDT on Monad to use USDT0, and finds USDT0 as written', () => { + const monadModule = chainModule('monad') + if (monadModule === undefined) throw new Error('no module') + expect(tokenBySymbol(monadModule.tokens, 'usdt0')).toBe(USDT0) + expect(tokenBySymbol(monadModule.tokens, 'USDT')).toBeUndefined() + const refusalOf = (request: ChainActionRequest): string => { + try { + assertKnownTokens(monadModule, request) + } catch (e) { + return (e as Error).message + } + throw new Error('nothing was refused') + } + expect(refusalOf(swap({ tokenIn: 'USDT', tokenOut: 'MON' }))).toMatch(/USDT0/) + expect(refusalOf(swap({ tokenIn: 'USDC', tokenOut: 'usdt' }))).toMatch(/USDT0/) + expect(() => assertKnownTokens(monadModule, swap({ tokenOut: 'MON' }))).not.toThrow() + }) + + it('prices a token from 1Click by its asset id', async () => { + await expect(chainModule('avalanche')?.priceUsd(AVAX)).resolves.toBe(20) + await expect(chainModule('monad')?.priceUsd(MON)).resolves.toBe(0.04) + await expect(chainModule('monad')?.priceUsd(USDT0)).resolves.toBe(1) + }) + + it('has no price, not an error, when 1Click cannot be read or does not list the token', async () => { + sim.tokens = sim.tokens.filter((t) => t['symbol'] !== 'MON') + await expect(chainModule('monad')?.priceUsd(MON)).resolves.toBeUndefined() + clearTokenCache() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('down') + }) + ) + await expect(chainModule('monad')?.priceUsd(MON)).resolves.toBeUndefined() + }) +}) + +describe('asset ids on these chains', () => { + it('matches a contract in any casing, and the native coin as the entry with no contract', async () => { + await expect(oneClickAssetId('avalanche', AVAX_USDC)).resolves.toBe('nep245:avax-usdc') + await expect(oneClickAssetId('avalanche', AVAX)).resolves.toBe('nep245:avax-native') + await expect(oneClickAssetId('monad', MON)).resolves.toBe('nep245:monad-native') + await expect(oneClickAssetId('monad', USDT0)).resolves.toBe('nep245:monad-usdt0') + }) + + it('finds no route when 1Click lists another contract under the same symbol', async () => { + sim.tokens = sim.tokens.filter((t) => t['assetId'] !== 'nep245:avax-usdc') + await expect(oneClickAssetId('avalanche', AVAX_USDC)).resolves.toBeUndefined() + await expect(venueAvalanche.quote?.(swap(), ACCOUNT)).rejects.toBeInstanceOf(ChainActionRefused) + expect(sim.quotes).toEqual([]) + }) + + it('finds no route when the decimals disagree', async () => { + sim.tokens = sim.tokens.map((t) => + t['assetId'] === 'nep245:monad-usdc' ? { ...t, decimals: 18 } : t + ) + await expect(oneClickAssetId('monad', MONAD_USDC)).resolves.toBeUndefined() + }) + + it('does not take another chain’s entry for a contract', async () => { + sim.tokens = sim.tokens.map((t) => + t['assetId'] === 'nep245:monad-usdc' ? { ...t, blockchain: 'avax' } : t + ) + await expect(oneClickAssetId('monad', MONAD_USDC)).resolves.toBeUndefined() + }) +}) + +describe('the race on these chains', () => { + it('reaches the race from what a person types', () => { + expect(chainCapabilities('avalanche')).toBeDefined() + expect(chainCapabilities('monad')).toBeDefined() + expect(chainCapabilities('arc')).toBeUndefined() + expect(parseChainAction('swap 10 USDC to USDT0')).toMatchObject({ + kind: 'swap', + tokenIn: 'USDC', + tokenOut: 'USDT0', + }) + expect(capabilitySheet('avalanche')).toContain('Agents compete') + expect(capabilitySheet('monad')).toContain('Agents compete') + expect(capabilitySheet('monad')).not.toMatch(/Supply:|Stake:|Send:|limit order/) + expect(capabilitySheet('arc')).not.toContain('Agents compete') + }) + + it('quotes one dry route, with no deposit address, spending the native coin too', async () => { + const monadModule = chainModule('monad') + if (monadModule === undefined) throw new Error('no module') + const routes = await quoteChainRoutes( + monadModule, + swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' }), + ACCOUNT + ) + expect(routes.map((r) => r.id)).toEqual(['1click-monad-1']) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true]) + expect(JSON.stringify(routes)).not.toContain(DEPOSIT) + expect(routes[0]?.receiveAmount).toBe('4') + expect(singleRouteNote(routes.length, 'Monad')).toBe( + "One route available on Monad. The agents are judging whether it's worth taking." + ) + }) + + it('gives neither chain Stellar prices, Stellar assets or the other chain’s venues', () => { + for (const chain of ['avalanche', 'monad']) { + const market = buildMarketContext(chain, {}, 'mainnet') + expect(market.prices).toEqual({}) + expect(market.assets ?? []).toEqual([]) + const ids = market.venues.map((v) => v.id) + expect(ids).not.toContain(chain === 'avalanche' ? '1click-monad' : '1click-avalanche') + expect(ids).not.toContain('uniswap') + } + }) + + it('leaves Arc’s market as it was', () => { + const market = buildMarketContext('arc', {}, 'testnet') + expect(Object.keys(market.prices).length).toBeGreaterThan(0) + }) +}) + +describe('the Apps page rows', () => { + const on = (slug: 'avalanche' | 'monad' | 'arc' | 'stellar'): string[] => + venues.filter((v) => venueOnChain(v, CHAIN_DESCRIPTORS[slug])).map((v) => v.id) + + it('shows each chain its own row and no venue that does not name it', () => { + expect(on('avalanche')).toEqual(['1click-avalanche']) + expect(on('monad')).toEqual(['1click-monad']) + }) + + it('changes nothing for another family or for Arc', () => { + expect(on('stellar')).toEqual(venues.filter((v) => v.family === 'stellar').map((v) => v.id)) + const arc = on('arc') + expect(arc).toContain('uniswap') + expect(arc).not.toContain('1click-avalanche') + expect(arc).not.toContain('1click-monad') + const marketOnArc = buildMarketContext('arc', {}, 'testnet').venues.map((v) => v.id) + expect(marketOnArc).toEqual(arc) + }) + + it('claims no execution, because the rows are client-safe and can’t read the secret', () => { + for (const id of ['1click-avalanche', '1click-monad']) { + const row = venues.find((v) => v.id === id) + expect(row?.family).toBe('evm') + expect(row?.integration).toBeUndefined() + } + }) +}) + +describe('assertSafe: what may be signed', () => { + const client = (): never => { + throw new Error('assertSafe must not read the chain') + } + const avalancheDeposit = createEvmDeposit('avalanche', client) + const monadDeposit = createEvmDeposit('monad', client) + + const nativeOrder = { + account: ACCOUNT, + depositAddress: DEPOSIT, + memo: undefined, + amount: '1.5', + token: AVAX, + } + const usdcOrder = { ...nativeOrder, amount: '25', token: AVAX_USDC } + + const transfer = (to: string, units: bigint): string => + encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [to as Address, units], + }) + + const nativePayload = (change: Json = {}): SigningPayload => + ({ + family: 'evm', + chainId: 43114, + to: DEPOSIT, + data: '0x', + value: ONE_AND_A_HALF_AVAX, + ...change, + }) as SigningPayload + + const tokenPayload = (change: Json = {}): SigningPayload => + ({ + family: 'evm', + chainId: 43114, + to: AVAX_USDC.id, + data: transfer(DEPOSIT, USD_UNITS), + value: '0', + ...change, + }) as SigningPayload + + it('admits a native transfer only with the exact value and empty data', () => { + expect(() => avalancheDeposit.assertSafe(nativePayload(), nativeOrder)).not.toThrow() + }) + + it.each<[string, Json]>([ + ['a value one unit more', { value: '1500000000000000001' }], + ['a value one unit less', { value: '1499999999999999999' }], + ['no value', { value: '0' }], + ['a value in hex', { value: '0x14d1120d7b160000' }], + ['a padded value', { value: `0${ONE_AND_A_HALF_AVAX}` }], + ['data on the transfer', { data: '0xdeadbeef' }], + ['a transfer call as data', { data: transfer(DEPOSIT, USD_UNITS) }], + ['no data field at all', { data: '' }], + ['another recipient', { to: OTHER }], + ['the token contract as recipient', { to: AVAX_USDC.id }], + ['Monad’s chain id', { chainId: 143 }], + ])('refuses a native transfer with %s', (_name, change) => { + expect(() => avalancheDeposit.assertSafe(nativePayload(change), nativeOrder)).toThrow( + ChainActionRefused + ) + }) + + it('admits an ERC-20 transfer only with no value and exactly one transfer call', () => { + expect(() => avalancheDeposit.assertSafe(tokenPayload(), usdcOrder)).not.toThrow() + expect(() => + avalancheDeposit.assertSafe(tokenPayload({ to: AVAX_USDC.id.toLowerCase() }), usdcOrder) + ).not.toThrow() + }) + + it.each<[string, Json]>([ + ['value attached', { value: '1' }], + ['the deposit as the call target', { to: DEPOSIT }], + ['USDT in place of USDC', { to: AVAX_USDT.id }], + ['another recipient', { data: transfer(OTHER, USD_UNITS) }], + ['a different amount', { data: transfer(DEPOSIT, USD_UNITS + BigInt(1)) }], + ['bytes after the transfer', { data: `${transfer(DEPOSIT, USD_UNITS)}00` }], + [ + 'dirty padding before the address', + { data: `0xa9059cbb${'f'.repeat(24)}${'2'.repeat(40)}${word('17d7840')}` }, + ], + ['an approve', { data: `0x095ea7b3${addressWord(DEPOSIT)}${word('17d7840')}` }], + ['no calldata', { data: '0x' }], + ['Monad’s chain id', { chainId: 143 }], + ])('refuses an ERC-20 transfer with %s', (_name, change) => { + expect(() => avalancheDeposit.assertSafe(tokenPayload(change), usdcOrder)).toThrow( + ChainActionRefused + ) + }) + + it('requires 143 on Monad and refuses 43114 there', () => { + const order = { ...nativeOrder, amount: '100', token: MON } + const units = '100000000000000000000' + const payload = (chainId: number): SigningPayload => ({ + family: 'evm', + chainId, + to: DEPOSIT, + data: '0x', + value: units, + }) + expect(() => monadDeposit.assertSafe(payload(143), order)).not.toThrow() + expect(() => monadDeposit.assertSafe(payload(43114), order)).toThrow(/Monad/) + }) + + it('never assumes USDC: a USDT order is a USDT transfer, and no token at all is refused', () => { + const usdtOrder = { ...usdcOrder, token: AVAX_USDT } + expect(() => + avalancheDeposit.assertSafe(tokenPayload({ to: AVAX_USDT.id }), usdtOrder) + ).not.toThrow() + expect(() => avalancheDeposit.assertSafe(tokenPayload(), usdtOrder)).toThrow(ChainActionRefused) + const { token: _absent, ...noToken } = usdcOrder + expect(() => avalancheDeposit.assertSafe(tokenPayload(), noToken)).toThrow(ChainActionRefused) + }) + + it('refuses a token from another chain’s list', () => { + expect(() => + avalancheDeposit.assertSafe(tokenPayload({ to: MONAD_USDC.id }), { + ...usdcOrder, + token: MONAD_USDC, + }) + ).toThrow(ChainActionRefused) + }) + + it('refuses another family, and a memo', () => { + expect(() => + avalancheDeposit.assertSafe({ family: 'solana', transaction: 'AAAA' }, nativeOrder) + ).toThrow(ChainActionRefused) + expect(() => + avalancheDeposit.assertSafe(nativePayload(), { ...nativeOrder, memo: '42' }) + ).toThrow(/memo/i) + }) + + it('takes no token contract and no zero address as a deposit address', () => { + expect(avalancheDeposit.isDepositAddress(DEPOSIT)).toBe(true) + expect(avalancheDeposit.isDepositAddress(AVAX_USDC.id.toLowerCase())).toBe(false) + expect(avalancheDeposit.isDepositAddress(AVAX_USDT.id)).toBe(false) + expect(monadDeposit.isDepositAddress(USDT0.id)).toBe(false) + expect(monadDeposit.isDepositAddress(ZERO)).toBe(false) + expect(monadDeposit.isAccount(ZERO)).toBe(false) + expect(monadDeposit.isAccount('not an address')).toBe(false) + }) +}) + +describe('prepare', () => { + it('builds a native transfer of exactly the quoted amount, after a dry and a real quote', async () => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(sim.quotes.map((q) => q['dry'])).toEqual([true, false]) + expect(prepared.signing).toEqual({ + family: 'evm', + chainId: 43114, + to: DEPOSIT, + data: '0x', + value: ONE_AND_A_HALF_AVAX, + }) + expect(prepared.pay).toEqual({ amount: '1.5', symbol: 'AVAX' }) + expect(prepared.context?.['amount']).toBe(ONE_AND_A_HALF_AVAX) + expect(prepared.context?.['token']).toBe(EVM_NATIVE_ID) + expect(prepared.summary).toContain('Avalanche') + expect(sim.quotes[0]?.['originAsset']).toBe('nep245:avax-native') + }) + + it('builds a transfer of the token asked for, not USDC', async () => { + const prepared = await venueAvalanche.prepare(swap({ tokenIn: 'USDT' }), ACCOUNT) + const expected: SigningPayload = { + family: 'evm', + chainId: 43114, + to: AVAX_USDT.id, + data: encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS], + }), + value: '0', + } + expect(prepared.signing).toEqual(expected) + expect(prepared.context?.['token']).toBe(AVAX_USDT.id) + expect(() => + venueAvalanche.assertSafe(prepared.signing, ACCOUNT, prepared.context) + ).not.toThrow() + }) + + it('builds on Monad for chain 143, through the whole prepare path', async () => { + const monadModule = chainModule('monad') + if (monadModule === undefined) throw new Error('no module') + const prepared = await prepareChainAction( + monadModule, + swap({ tokenIn: 'MON', tokenOut: 'USDT0', amount: '100' }), + ACCOUNT, + {} + ) + expect(prepared.venueId).toBe('1click-monad') + expect(prepared.signing).toMatchObject({ + family: 'evm', + chainId: 143, + to: DEPOSIT, + value: '100000000000000000000', + }) + expect(prepared.summary).toContain('Monad') + }) + + it('refuses a deposit address that is the token contract, or not an address', async () => { + for (const bad of [AVAX_USDC.id, 'not-an-address', ZERO]) { + sim.depositAddress = bad + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).rejects.toThrow(/deposit address/i) + } + }) + + it('refuses an account that is not an EVM address, in words that read properly', async () => { + await expect(venueAvalanche.prepare(swap(), 'alice.near')).rejects.toThrow( + 'That is not an Avalanche wallet address.' + ) + await expect(venueMonad.prepare(swap({ tokenOut: 'MON' }), 'alice.near')).rejects.toThrow( + 'That is not a Monad wallet address.' + ) + }) + + describe('reading what the wallet holds', () => { + const reserve = (chain: 'avalanche' | 'monad'): bigint => BigInt(EVM_CHAINS[chain].gasReserve) + + function expectNoAddressAsked(): void { + expect(sim.quotes.filter((q) => q['dry'] === false)).toEqual([]) + } + + it('reads the token from its own contract, for this account', async () => { + const seen: string[][] = [] + rpc.tokenBalance = async (tokenId, account) => { + seen.push([tokenId, account]) + return USD_UNITS + } + await venueAvalanche.prepare(swap({ tokenIn: 'USDT' }), ACCOUNT) + expect(seen).toEqual([[AVAX_USDT.id, ACCOUNT]]) + }) + + it('refuses less of the token than the swap needs, before any deposit address', async () => { + rpc.tokenBalance = async () => USD_UNITS - BigInt(1) + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).rejects.toThrow( + /less USDC.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + }) + + it('takes exactly enough of the token', async () => { + rpc.tokenBalance = async () => USD_UNITS + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).resolves.toBeDefined() + }) + + it('refuses a token swap with too little of the coin that pays for gas', async () => { + rpc.nativeBalance = async () => reserve('monad') - BigInt(1) + await expect(venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT)).rejects.toThrow( + /MON.*network fee.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + }) + + it('keeps gas back when the native coin is what is being spent', async () => { + const amount = BigInt(ONE_AND_A_HALF_AVAX) + rpc.nativeBalance = async () => amount + await expect(venueAvalanche.prepare(avaxSwap(), ACCOUNT)).rejects.toThrow( + /network fee.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + rpc.nativeBalance = async () => amount + reserve('avalanche') - BigInt(1) + await expect(venueAvalanche.prepare(avaxSwap(), ACCOUNT)).rejects.toBeInstanceOf( + ChainActionRefused + ) + expectNoAddressAsked() + rpc.nativeBalance = async () => amount + reserve('avalanche') + await expect(venueAvalanche.prepare(avaxSwap(), ACCOUNT)).resolves.toBeDefined() + }) + + it.each<[string, () => void]>([ + [ + 'the token', + () => { + rpc.tokenBalance = async () => { + throw new Error('rpc down') + } + }, + ], + [ + 'the gas coin', + () => { + rpc.nativeBalance = async () => { + throw new Error('rpc down') + } + }, + ], + ])('fails closed when %s cannot be read', async (_name, arrange) => { + arrange() + await expect(venueAvalanche.prepare(swap(), ACCOUNT)).rejects.toThrow( + /Could not read.*Avalanche.*Nothing was signed or sent\.$/ + ) + expectNoAddressAsked() + }) + + it('does not read balances for a race quote', async () => { + rpc.tokenBalance = async () => { + throw new Error('a quote must not read this') + } + await expect(venueAvalanche.quote?.(swap(), ACCOUNT)).resolves.toBeDefined() + }) + }) + + describe('the signed context', () => { + it.each<[string, Record]>([ + ['another deposit address', { depositAddress: OTHER }], + ['another amount', { amount: '1' }], + ['another token', { token: AVAX_USDC.id }], + ['a later deadline', { deadline: '2099-01-01T00:00:00.000Z' }], + ['another proof', { proof: 'forged' }], + ])('refuses %s', async (_name, change) => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(() => + venueAvalanche.assertSafe(prepared.signing, ACCOUNT, { ...prepared.context, ...change }) + ).toThrow(ChainActionRefused) + }) + + it('does not pass for another account or on the other chain', async () => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(() => venueAvalanche.assertSafe(prepared.signing, OTHER, prepared.context)).toThrow( + ChainActionRefused + ) + expect(() => + venueMonad.assertSafe({ ...prepared.signing, chainId: 143 } as SigningPayload, ACCOUNT, { + ...(prepared.context as Record), + }) + ).toThrow(ChainActionRefused) + }) + + it('does not let the browser spend USDC under a proof that named USDT', async () => { + const prepared = await venueAvalanche.prepare(swap({ tokenIn: 'USDT' }), ACCOUNT) + expect(() => + venueAvalanche.assertSafe( + { ...prepared.signing, to: AVAX_USDC.id } as SigningPayload, + ACCOUNT, + prepared.context + ) + ).toThrow(ChainActionRefused) + }) + }) +}) + +describe('submit: what the chain says the wallet sent', () => { + describe('a native transfer', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + rpc.transactionValue = async () => BigInt(ONE_AND_A_HALF_AVAX) + }) + + it('accepts the transfer, tells 1Click, and is done when 1Click says so', async () => { + rpc.waitForReceipt = async () => receipt() + await expect(submitTo(prepared)).resolves.toEqual({ + ok: true, + hash: HASH, + explorerUrl: `https://snowtrace.io/tx/${HASH}`, + }) + expect(sim.notified).toEqual([{ txHash: HASH, depositAddress: DEPOSIT }]) + }) + + it.each<[string, Json]>([ + ['from another account', { from: OTHER }], + ['to another address', { to: OTHER }], + ['to no address', { to: null }], + ])('does not take a receipt %s as this order’s transfer', async (_name, change) => { + rpc.waitForReceipt = async () => receipt(change) + const result = await submitTo(prepared) + isPending(result) + expect(result).toMatchObject({ error: expect.stringMatching(/not this swap/) }) + expect(sim.notified).toEqual([]) + }) + + it('reads the value, since a receipt has none, and refuses any other value', async () => { + rpc.waitForReceipt = async () => receipt() + rpc.transactionValue = async () => BigInt(ONE_AND_A_HALF_AVAX) - BigInt(1) + const result = await submitTo(prepared) + isPending(result) + expect(sim.notified).toEqual([]) + }) + + it('stays pending when a landed transfer’s value cannot be read', async () => { + rpc.waitForReceipt = async () => receipt() + rpc.transactionValue = async () => { + throw new Error('rpc down') + } + isPending(await submitTo(prepared)) + expect(sim.notified).toEqual([]) + }) + + it('calls a revert clean only when it is this account’s, to this deposit address', async () => { + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + const clean = await submitTo(prepared) + withoutPending(clean) + expect(clean).toMatchObject({ error: expect.stringMatching(/AVAX did not move/) }) + expect(sim.notified).toEqual([]) + + for (const change of [{ from: OTHER }, { to: OTHER }, { to: null }]) { + rpc.waitForReceipt = async () => receipt({ status: 'reverted', ...change }) + isPending(await submitTo(prepared)) + } + }) + }) + + describe('an ERC-20 transfer', () => { + let prepared: PreparedAction + const paying = (logs: unknown[], change: Json = {}): Json => + receipt({ to: AVAX_USDC.id, logs, ...change }) + + beforeEach(async () => { + prepared = await venueAvalanche.prepare(swap(), ACCOUNT) + }) + + it('accepts a Transfer log of exactly this amount, from the account to the deposit', async () => { + rpc.waitForReceipt = async () => paying([transferLog(AVAX_USDC.id, USD_UNITS)]) + await expect(submitTo(prepared)).resolves.toMatchObject({ ok: true, hash: HASH }) + expect(sim.notified).toHaveLength(1) + }) + + it.each<[string, () => Json]>([ + [ + 'from another account', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { from: OTHER }), + ], + ['to another contract', () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { to: OTHER })], + ['with no logs', () => paying([])], + ['with a log from another token', () => paying([transferLog(AVAX_USDT.id, USD_UNITS)])], + [ + 'with a log from another sender', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS, { from: OTHER })]), + ], + [ + 'with a log to another recipient', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS, { to: OTHER })]), + ], + [ + 'with a log of another amount', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS + BigInt(1))]), + ], + ])('does not take a receipt %s as this order’s transfer', async (_name, build) => { + rpc.waitForReceipt = async () => build() + isPending(await submitTo(prepared)) + expect(sim.notified).toEqual([]) + }) + + it('calls a revert clean only when it is this account’s, on this token', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + const clean = await submitTo(prepared) + withoutPending(clean) + expect(clean).toMatchObject({ error: expect.stringMatching(/USDC did not move/) }) + + rpc.waitForReceipt = async () => paying([], { status: 'reverted', from: OTHER }) + isPending(await submitTo(prepared)) + rpc.waitForReceipt = async () => paying([], { status: 'reverted', to: DEPOSIT }) + isPending(await submitTo(prepared)) + }) + + it('keeps the latch set on a clean revert, because the wallet sent for itself', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + const clean = await submitTo(prepared) + expect(afterSubmitting(clean, 'evm')).toMatchObject({ latched: true }) + }) + }) + + describe('what is never called clean', () => { + let prepared: PreparedAction + let hasNoClaimOfNothing: (result: SubmitResult) => void + + beforeEach(async () => { + prepared = await venueAvalanche.prepare(swap(), ACCOUNT) + hasNoClaimOfNothing = (result) => { + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/nothing was/i) }) + } + }) + + it('a receipt that does not come, which is what a hash from another chain gives', async () => { + rpc.waitForReceipt = async () => { + throw new Error('timed out') + } + const result = await submitTo(prepared) + hasNoClaimOfNothing(result) + expect(result).toMatchObject({ error: expect.stringContaining(HASH) }) + expect(sim.notified).toEqual([]) + }) + + it.each(['0x1234', `0x${'g'.repeat(64)}`, HASH.slice(2), `${HASH}00`, ''])( + 'a wallet answer that is not a hash: %s', + async (hash) => { + const result = await submitTo(prepared, venueAvalanche, { hash }) + hasNoClaimOfNothing(result) + expect(rpc.reads).toEqual([]) + } + ) + + it('a quote that expired once the wallet had already sent: it is still followed', async () => { + rpc.waitForReceipt = async () => + receipt({ to: AVAX_USDC.id, logs: [transferLog(AVAX_USDC.id, USD_UNITS)] }) + const issuedAt = Date.now() + vi.useFakeTimers({ now: issuedAt + 10 * 60_000, toFake: ['Date'] }) + await expect(submitTo(prepared)).resolves.toMatchObject({ ok: true }) + expect(sim.notified).toHaveLength(1) + }) + + it.each<[string, Record]>([ + ['a changed deposit address', { depositAddress: OTHER }], + ['a changed amount', { amount: '1' }], + ['a forged proof', { proof: 'forged' }], + ])('details that were not issued, %s: the wallet may have sent anyway', async (_n, change) => { + const result = await submitTo(prepared, venueAvalanche, { + context: { ...(prepared.context as Record), ...change }, + }) + hasNoClaimOfNothing(result) + expect(result).toMatchObject({ error: expect.stringContaining(HASH) }) + expect(rpc.reads).toEqual([]) + expect(sim.notified).toEqual([]) + }) + + it('a proof made for the other chain', async () => { + const result = await submitTo(prepared, venueMonad) + hasNoClaimOfNothing(result) + expect(rpc.reads).toEqual([]) + }) + + it('a proof made for another account', async () => { + const result = await submitTo(prepared, venueAvalanche, { account: OTHER }) + hasNoClaimOfNothing(result) + }) + + it('another family handed to this chain', async () => { + const result = await (venueAvalanche.submit as NonNullable)({ + account: ACCOUNT, + venueId: venueAvalanche.id, + signed: { family: 'solana', signedTransaction: 'AAAA' }, + context: prepared.context, + }) + hasNoClaimOfNothing(result) + }) + + it('a read that throws anything at all', async () => { + rpc.waitForReceipt = async () => { + throw new ChainActionRefused('a refusal is no proof once the wallet has sent') + } + hasNoClaimOfNothing(await submitTo(prepared)) + }) + + it('a refusal thrown while reading what came back', async () => { + rpc.waitForReceipt = async () => ({ + get from(): string { + throw new ChainActionRefused('a refusal is no proof once the wallet has sent') + }, + }) + hasNoClaimOfNothing(await submitTo(prepared)) + }) + }) + + describe('after the transfer lands', () => { + let prepared: PreparedAction + + beforeEach(async () => { + prepared = await venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT) + rpc.waitForReceipt = async () => + receipt({ to: MONAD_USDC.id, logs: [transferLog(MONAD_USDC.id, USD_UNITS)] }) + }) + + it('links Monad’s explorer', async () => { + await expect(submitTo(prepared, venueMonad)).resolves.toEqual({ + ok: true, + hash: HASH, + explorerUrl: `https://monadvision.com/tx/${HASH}`, + }) + }) + + it.each(['REFUNDED', 'FAILED', 'INCOMPLETE_DEPOSIT'])( + 'calls 1Click’s %s pending, and says where funds should return', + async (status) => { + sim.statuses = [status] + const result = await submitTo(prepared, venueMonad) + isPending(result) + expect(result).toMatchObject({ error: expect.stringContaining(ACCOUNT) }) + } + ) + + it('answers a running swap as pending inside the route’s 60 seconds', async () => { + sim.statuses = ['PROCESSING'] + vi.useFakeTimers() + const started = Date.now() + let settledAt: number | undefined + const running = submitTo(prepared, venueMonad).then((r) => { + settledAt = Date.now() + return r + }) + await vi.advanceTimersByTimeAsync(60_000) + isPending(await running) + expect((settledAt as number) - started).toBeLessThan(60_000) + }) + + it('does not mistake a recipient in another casing for another swap', async () => { + const mixed = '0xAbCdEf0123456789aBcDeF0123456789abcdef01' + const mine = await venueMonad.prepare(swap({ tokenOut: 'MON' }), mixed) + rpc.waitForReceipt = async () => + receipt({ + from: mixed, + to: MONAD_USDC.id, + logs: [transferLog(MONAD_USDC.id, USD_UNITS, { from: mixed })], + }) + sim.statusRecipient = mixed.toLowerCase() + await expect(submitTo(mine, venueMonad, { account: mixed })).resolves.toMatchObject({ + ok: true, + }) + }) + + it('holds when 1Click’s record names someone else', async () => { + sim.statusRecipient = OTHER + const result = await submitTo(prepared, venueMonad) + isPending(result) + expect(result).toMatchObject({ error: expect.stringMatching(/different swap/) }) + }) + }) + + describe('through the real client', () => { + function transport(answers: Record) { + return custom({ + request: async ({ method }: { method: string }) => answers[method] ?? null, + }) + } + + const order = { + account: ACCOUNT, + depositAddress: DEPOSIT, + memo: undefined, + amount: '100', + token: MON, + } + + const rpcReceipt = { + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x5208', + effectiveGasPrice: '0x1', + from: ACCOUNT, + gasUsed: '0x5208', + logs: [], + logsBloom: `0x${'0'.repeat(512)}`, + status: '0x1', + to: DEPOSIT, + transactionHash: HASH, + transactionIndex: '0x0', + type: '0x2', + } + + const rpcTransaction = (hexValue: string): Json => ({ + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: '0x1', + from: ACCOUNT, + gas: '0x5208', + gasPrice: '0x1', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + hash: HASH, + input: '0x', + nonce: '0x0', + to: DEPOSIT, + transactionIndex: '0x0', + value: hexValue, + type: '0x2', + accessList: [], + chainId: '0x8f', + v: '0x1', + r: `0x${'1'.repeat(64)}`, + s: `0x${'2'.repeat(64)}`, + yParity: '0x1', + }) + + async function deposit(answers: Record) { + const { evmClient } = await import('../chains/evm/client') + return createEvmDeposit('monad', () => evmClient('monad', transport(answers))) + } + + const ONE_HUNDRED_MON_HEX = `0x${(BigInt(100) * BigInt(10) ** BigInt(18)).toString(16)}` + + it('confirms a real receipt and a real transaction shape', async () => { + const chain = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: rpcReceipt, + eth_getTransactionByHash: rpcTransaction(ONE_HUNDRED_MON_HEX), + }) + await expect(chain.submit({ family: 'evm', hash: HASH }, order)).resolves.toMatchObject({ + ok: true, + }) + }) + + it('refuses a real transaction of another value', async () => { + const chain = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: rpcReceipt, + eth_getTransactionByHash: rpcTransaction('0x1'), + }) + isPending(await chain.submit({ family: 'evm', hash: HASH }, order)) + }) + + it('gives up well inside the route’s limit on a hash this chain has not seen', async () => { + vi.useFakeTimers() + const chain = await deposit({ eth_blockNumber: '0x2' }) + let settled: SubmitResult | undefined + const running = chain.submit({ family: 'evm', hash: HASH }, order).then((r) => { + settled = r + return r + }) + await vi.advanceTimersByTimeAsync(15_000) + expect(settled).toBeUndefined() + await vi.advanceTimersByTimeAsync(10_000) + isPending(await running) + }) + + it('reads the token’s balance from its contract and the coin as the native balance', async () => { + const seen: { method: string; params: unknown[] }[] = [] + const { evmClient } = await import('../chains/evm/client') + const client = evmClient( + 'avalanche', + custom({ + request: async ({ method, params }: { method: string; params: unknown[] }) => { + seen.push({ method, params }) + return method === 'eth_call' ? `0x${word('bebc20')}` : '0x5' + }, + }) + ) + await expect(client.tokenBalance(AVAX_USDT.id as Address, ACCOUNT as Address)).resolves.toBe( + BigInt(12_500_000) + ) + await expect(client.nativeBalance(ACCOUNT as Address)).resolves.toBe(BigInt(5)) + const call = seen.find((s) => s.method === 'eth_call')?.params[0] as { to: string } + expect(call.to.toLowerCase()).toBe(AVAX_USDT.id.toLowerCase()) + }) + }) + + describe('which network the client talks to', () => { + function rpcCalls(): string[] { + const urls: string[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL) => { + urls.push(String(input)) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x5' })) + }) + ) + return urls + } + + // The mock hands back the real client only for a transport, so these go through the original. + async function realClient(chain: 'avalanche' | 'monad') { + const mocked = (await import('../chains/evm/client')) as unknown as { + realEvmClient: typeof import('../chains/evm/client').evmClient + } + return mocked.realEvmClient(chain) + } + + it('uses MONAD_RPC_URL when it is set', async () => { + const urls = rpcCalls() + vi.stubEnv('MONAD_RPC_URL', ' https://monad.example.test/rpc ') + const client = await realClient('monad') + await client.nativeBalance(ACCOUNT as Address) + expect(urls).toEqual(['https://monad.example.test/rpc']) + }) + + it('falls back to viem’s default for Monad', async () => { + const urls = rpcCalls() + vi.stubEnv('MONAD_RPC_URL', '') + const client = await realClient('monad') + await client.nativeBalance(ACCOUNT as Address) + expect(urls.map((url) => new URL(url).origin)).toEqual([ + new URL(monad.rpcUrls.default.http[0]).origin, + ]) + }) + + it('uses AVALANCHE_RPC_URL on Avalanche', async () => { + const urls = rpcCalls() + vi.stubEnv('AVALANCHE_RPC_URL', 'https://avax.example.test/rpc') + const client = await realClient('avalanche') + await client.nativeBalance(ACCOUNT as Address) + expect(urls).toEqual(['https://avax.example.test/rpc']) + }) + }) +}) + +describe('the submit route', () => { + async function post(chain: string, body: unknown): Promise { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + return POST( + new Request(`http://localhost/api/chain/${chain}/submit`, { + method: 'POST', + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chain }) } + ) + } + + it.each<[string, typeof venueAvalanche, () => ChainActionRequest]>([ + ['avalanche', venueAvalanche, () => avaxSwap()], + ['monad', venueMonad, () => swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' })], + ])('accepts { family: evm, hash } on %s and follows it', async (chain, venue, request) => { + const prepared = await venue.prepare(request(), ACCOUNT) + rpc.waitForReceipt = async () => receipt() + rpc.transactionValue = async () => + BigInt((prepared.signing as Extract).value) + const res = await post(chain, { + account: ACCOUNT, + venueId: venue.id, + signed: { family: 'evm', hash: HASH }, + context: prepared.context, + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toMatchObject({ ok: true, hash: HASH }) + }) + + it('answers a receipt from another account as pending, which holds the latch', async () => { + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + rpc.waitForReceipt = async () => receipt({ from: OTHER }) + const res = await post('avalanche', { + account: ACCOUNT, + venueId: venueAvalanche.id, + signed: { family: 'evm', hash: HASH }, + context: prepared.context, + }) + expect(res.status).toBe(202) + const body = (await res.json()) as SubmitResult + expect(body).toMatchObject({ ok: false, pending: true }) + expect(afterSubmitting(body, 'evm')).toMatchObject({ phase: 'pending', latched: true }) + }) + + it.each<[string, string, unknown]>([ + ['solana bytes on Avalanche', 'avalanche', { family: 'solana', signedTransaction: 'AAAA' }], + ['a near hash on Monad', 'monad', { family: 'near', hashes: ['abc'] }], + ])('refuses %s', async (_name, chain, signed) => { + const res = await post(chain, { + account: ACCOUNT, + venueId: `1click-${chain}`, + signed, + context: {}, + }) + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'wrong_chain' }) + }) + + it('refuses an EVM answer on a chain of another family', async () => { + const res = await post('solana', { + account: ACCOUNT, + venueId: '1click-solana', + signed: { family: 'evm', hash: HASH }, + context: {}, + }) + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'wrong_chain' }) + }) +}) + +describe('a stale card, before the wallet opens', () => { + const NOW = Date.parse('2026-11-04T12:00:00.000Z') + const card = ( + family: SigningPayload['family'], + deadline?: string + ): Pick => ({ + signing: { family } as SigningPayload, + ...(deadline !== undefined ? { context: { deadline } } : {}), + }) + + it('is stopped on an EVM chain, where no later check could stop the wallet sending', () => { + expect(quoteExpiredBeforeSigning(card('evm', '2026-11-04T11:59:59.000Z'), NOW)).toBe(true) + expect(quoteExpiredBeforeSigning(card('evm', '2026-11-04T12:00:01.000Z'), NOW)).toBe(false) + expect(quoteExpiredBeforeSigning(card('evm', 'not a date'), NOW)).toBe(true) + expect(quoteExpiredBeforeSigning(card('evm'), NOW)).toBe(false) + }) + + it('is left to the server on chains whose bytes it sends, and to NEAR as it was', () => { + for (const family of ['solana', 'sui', 'near'] as const) { + expect(quoteExpiredBeforeSigning(card(family, '2020-01-01T00:00:00.000Z'), NOW)).toBe(false) + } + }) +}) + +describe('the wallet', () => { + async function adapters() { + return import('../adapters/evm-adapter') + } + + it('sends the native value as a bigint, on Monad', async () => { + const { monadAdapter } = await adapters() + const out = await monadAdapter.signPrepared?.({ + family: 'evm', + chainId: 143, + to: DEPOSIT, + data: '0x', + value: '100000000000000000000', + }) + expect(out).toEqual({ ok: true, signed: { family: 'evm', hash: WALLET_HASH } }) + expect(wallet.calls).toEqual([ + ['send', { to: DEPOSIT, data: '0x', value: BigInt('100000000000000000000'), chainId: 143 }], + ]) + }, 30_000) + + it('switches to Monad first when the wallet is elsewhere', async () => { + wallet.account = { status: 'connected', chainId: 43114 } + const { monadAdapter } = await adapters() + await monadAdapter.signPrepared?.({ + family: 'evm', + chainId: 143, + to: DEPOSIT, + data: '0x', + value: '1', + }) + expect(wallet.calls.map((c) => c[0])).toEqual(['switch', 'send']) + expect(wallet.calls[0]?.[1]).toEqual({ chainId: 143 }) + }) + + it('sends a native value on Avalanche too', async () => { + wallet.account = { status: 'connected', chainId: 43114 } + const { avalancheAdapter } = await adapters() + await avalancheAdapter.signPrepared?.({ + family: 'evm', + chainId: 43114, + to: DEPOSIT, + data: '0x', + value: ONE_AND_A_HALF_AVAX, + }) + expect(wallet.calls[0]?.[1]).toMatchObject({ value: BigInt(ONE_AND_A_HALF_AVAX) }) + }) + + it.each<[string, SigningPayload]>([ + [ + 'Avalanche’s chain id', + { family: 'evm', chainId: 43114, to: DEPOSIT, data: '0x', value: '1' }, + ], + [ + 'a value that is not decimal', + { family: 'evm', chainId: 143, to: DEPOSIT, data: '0x', value: '0x10' }, + ], + [ + 'a value that is not a number', + { family: 'evm', chainId: 143, to: DEPOSIT, data: '0x', value: 'lots' }, + ], + ['another family', { family: 'solana', transaction: 'AAAA' }], + ])('refuses %s on Monad without touching the wallet', async (_name, payload) => { + const { monadAdapter } = await adapters() + await expect(monadAdapter.signPrepared?.(payload)).resolves.toMatchObject({ + ok: false, + reason: 'wallet_error', + }) + expect(wallet.calls).toEqual([]) + }) +}) diff --git a/apps/dapp/lib/__tests__/market-context-evm.test.ts b/apps/dapp/lib/__tests__/market-context-evm.test.ts new file mode 100644 index 00000000..0baa6b4b --- /dev/null +++ b/apps/dapp/lib/__tests__/market-context-evm.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +import { buildMarketContext } from '../agents/market-context' + +// Neither 1Click row claims execution today, so the family filter alone would pass by accident. +// A row that does claim it shows that one EVM chain is never handed another's venue. +vi.mock('../venues', async (original) => { + const actual = await original() + const row = (id: string, chain: string) => ({ + id, + family: 'evm' as const, + name: id, + category: 'swap' as const, + chains: [chain], + url: 'https://example.test', + bestFor: 'x', + integration: 'executes' as const, + }) + return { + ...actual, + venues: [...actual.venues, row('on-avalanche', 'Avalanche'), row('on-monad', 'Monad')], + } +}) + +describe('the market on EVM chains that share a family', () => { + it('gives each chain only the venues that name it', () => { + const ids = (chain: string): string[] => + buildMarketContext(chain, {}, 'mainnet').venues.map((v) => v.id) + expect(ids('avalanche')).toEqual(['on-avalanche']) + expect(ids('monad')).toEqual(['on-monad']) + }) +}) diff --git a/apps/dapp/lib/__tests__/oneclick-origin.test.ts b/apps/dapp/lib/__tests__/oneclick-origin.test.ts index 98441fbc..de93e3d5 100644 --- a/apps/dapp/lib/__tests__/oneclick-origin.test.ts +++ b/apps/dapp/lib/__tests__/oneclick-origin.test.ts @@ -530,16 +530,6 @@ describe('quote', () => { await expect(solana.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) expect(sim.quotes).toHaveLength(0) }) - - it('refuses plainly on a chain whose deposits are not built yet', async () => { - const avalanche = oneClickOriginVenue('avalanche') - expect(avalanche.id).toBe('1click-avalanche') - await expect(avalanche.quote?.(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) - await expect(avalanche.prepare(swap(), ME)).rejects.toBeInstanceOf(ChainActionRefused) - expect(() => avalanche.assertSafe({ family: 'solana', transaction: 'AAAA' }, ME)).toThrow( - ChainActionRefused - ) - }) }) describe('prepare', () => { @@ -822,6 +812,21 @@ describe('a browser that changes what it sends back', () => { expect(() => solana.assertSafe(payload(), ME, prepared.context)).toThrow(/expired/) }) + it.each([ + ['solana', { family: 'solana' as const, signedTransaction: '!!!' }], + ['sui', { family: 'sui' as const, transaction: '!!!', signature: 'x' }], + ])('stays a clean refusal on %s for bytes too broken to read', async (chain, signed) => { + const venue = chain === 'solana' ? solana : sui + const result = await venue.submit?.({ + account: chain === 'solana' ? ME : SUI_ME, + venueId: venue.id, + signed, + context: {}, + }) + expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/not what this app/) }) + expect(result).not.toHaveProperty('pending') + }) + it('is refused when the server no longer holds the secret', () => { vi.stubEnv('AUTH_SECRET', '') expect(() => solana.assertSafe(payload(), ME, prepared.context)).toThrow(ChainActionRefused) diff --git a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts index 0571d0c9..41eafdb9 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts @@ -220,6 +220,16 @@ describe('avalancheDeposit.assertSafe', () => { ) }) + it('refuses an order naming a token other than USDC, which is all this deposit moves', () => { + const usdt = { symbol: 'USDT', id: OTHER_TOKEN, decimals: 6 } + expectRefused(() => avalancheDeposit.assertSafe(payload(), { ...plain, token: usdt }), /USDC/) + }) + + it('takes an order naming USDC as one naming nothing', () => { + const usdc = { symbol: 'USDC', id: USDC.toLowerCase(), decimals: 6 } + expect(() => avalancheDeposit.assertSafe(payload(), { ...plain, token: usdc })).not.toThrow() + }) + it('refuses an order with a memo, which this chain cannot carry', () => { expectRefused(() => avalancheDeposit.assertSafe(payload(), { ...plain, memo: '42' }), /memo/i) }) @@ -258,6 +268,12 @@ describe('avalancheDeposit.build', () => { expect(reads.sort()).toEqual([`avax ${ACCOUNT}`, `usdc ${ACCOUNT}`]) }) + it('never builds USDC for an order that names another token', async () => { + const deposit = createAvalancheDeposit(() => fakeClient()) + const usdt = { symbol: 'USDT', id: OTHER_TOKEN, decimals: 6 } + await expect(deposit.build({ ...plain, token: usdt })).rejects.toThrow(/USDC/) + }) + async function refusal(deposit: typeof avalancheDeposit, expectation = plain): Promise { return deposit.build(expectation).then( () => { @@ -335,6 +351,13 @@ describe('avalancheDeposit.submit', () => { }) ) + it('never confirms a USDC transfer for an order that names another token', async () => { + const usdt = { symbol: 'USDT', id: OTHER_TOKEN, decimals: 6 } + await expect( + withReceipt(receipt()).submit(signed, { ...plain, token: usdt }) + ).rejects.toBeInstanceOf(ChainActionRefused) + }) + it('confirms a transfer that paid this order', async () => { await expect(withReceipt(receipt()).submit(signed, plain)).resolves.toEqual({ ok: true, @@ -509,11 +532,11 @@ describe('the EVM adapters’ signPrepared', () => { return import('../adapters/evm-adapter') } - it('exists on Avalanche alone', async () => { + it('exists on Avalanche and Monad, and not on Arc', async () => { const { arcAdapter, avalancheAdapter, monadAdapter } = await adapters() expect(avalancheAdapter.signPrepared).toBeTypeOf('function') + expect(monadAdapter.signPrepared).toBeTypeOf('function') expect(arcAdapter.signPrepared).toBeUndefined() - expect(monadAdapter.signPrepared).toBeUndefined() // The first import of the adapter pulls in the wallet libraries, which takes // longer than the default when the whole suite is running. }, 30_000) diff --git a/apps/dapp/lib/adapters/evm-adapter.ts b/apps/dapp/lib/adapters/evm-adapter.ts index 0162dc78..86d651b9 100644 --- a/apps/dapp/lib/adapters/evm-adapter.ts +++ b/apps/dapp/lib/adapters/evm-adapter.ts @@ -13,7 +13,7 @@ import { getAccount, sendTransaction, switchChain } from 'wagmi/actions' import { avalanche, monad } from 'viem/chains' import type { ChainAdapter, ChainWallet } from '../chain-adapter' -import type { SignedOutcome, SigningPayload } from '../chains/types' +import { withArticle, type SignedOutcome, type SigningPayload } from '../chains/types' import { arcTestnet, wagmiConfig } from '../wagmi.config' import { isUserRejection } from '../wallet/connector' import { walletErrorMessage } from '../wallet/error-message' @@ -93,41 +93,55 @@ function evmAdapter( } } -async function signAvalanche(payload: SigningPayload): Promise { - if (payload.family !== 'evm') { - return { ok: false, reason: 'wallet_error', detail: 'That is not an Avalanche transaction.' } - } - if (payload.chainId !== avalanche.id) { - return { ok: false, reason: 'wallet_error', detail: 'That transaction is not for Avalanche.' } - } - const { status, chainId: walletChain } = getAccount(wagmiConfig) - if (status !== 'connected') { - return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' } - } - try { - if (walletChain !== avalanche.id) { - await switchChain(wagmiConfig, { chainId: avalanche.id }) +function signOn(name: string, chainId: 43114 | 143): NonNullable { + return async (payload: SigningPayload): Promise => { + if (payload.family !== 'evm') { + return { + ok: false, + reason: 'wallet_error', + detail: `That is not ${withArticle(name)} transaction.`, + } + } + if (payload.chainId !== chainId) { + return { ok: false, reason: 'wallet_error', detail: `That transaction is not for ${name}.` } + } + // BigInt would also read hex or an empty string, and this is real money. + if (!/^\d+$/.test(payload.value)) { + return { ok: false, reason: 'wallet_error', detail: 'That transaction’s value is not valid.' } + } + const { status, chainId: walletChain } = getAccount(wagmiConfig) + if (status !== 'connected') { + return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' } + } + try { + if (walletChain !== chainId) { + await switchChain(wagmiConfig, { chainId }) + } + const hash = await sendTransaction(wagmiConfig, { + to: payload.to as Address, + data: payload.data as Hex, + value: BigInt(payload.value), + chainId, + }) + return { ok: true, signed: { family: 'evm', hash } } + } catch (e) { + // viem's message goes on to list the whole request, so its short one is what is read. + const detail = e instanceof BaseError ? e.shortMessage : walletErrorMessage(e) + const declined = + (e instanceof BaseError && + e.walk((cause) => cause instanceof UserRejectedRequestError) !== null) || + isUserRejection(detail) + return declined + ? { ok: false, reason: 'rejected' } + : { ok: false, reason: 'wallet_error', detail } } - const hash = await sendTransaction(wagmiConfig, { - to: payload.to as Address, - data: payload.data as Hex, - value: BigInt(0), - chainId: avalanche.id, - }) - return { ok: true, signed: { family: 'evm', hash } } - } catch (e) { - // viem's message goes on to list the whole request, so its short one is what is read. - const detail = e instanceof BaseError ? e.shortMessage : walletErrorMessage(e) - const declined = - (e instanceof BaseError && - e.walk((cause) => cause instanceof UserRejectedRequestError) !== null) || - isUserRejection(detail) - return declined - ? { ok: false, reason: 'rejected' } - : { ok: false, reason: 'wallet_error', detail } } } export const arcAdapter = evmAdapter(arcDescriptor, arcTestnet.id) -export const avalancheAdapter = evmAdapter(avalancheDescriptor, avalanche.id, signAvalanche) -export const monadAdapter = evmAdapter(monadDescriptor, monad.id) +export const avalancheAdapter = evmAdapter( + avalancheDescriptor, + avalanche.id, + signOn('Avalanche', avalanche.id) +) +export const monadAdapter = evmAdapter(monadDescriptor, monad.id, signOn('Monad', monad.id)) diff --git a/apps/dapp/lib/agents/market-context.ts b/apps/dapp/lib/agents/market-context.ts index 9c8317ec..d44a1bed 100644 --- a/apps/dapp/lib/agents/market-context.ts +++ b/apps/dapp/lib/agents/market-context.ts @@ -18,7 +18,7 @@ import { createSoroswapAggregatorQuoter } from '../swap/sources/soroswap-aggrega import { fetchMarketPrices, toPriceTable } from '../swap/prices' import { REFERENCE_PRICES_USD } from '../parse-intent' import { tradeableSymbols, trustSummary, verificationOf } from '../swap/asset-registry' -import { isVenueOn, venues } from '../venues' +import { isVenueOn, venueOnChain, venues } from '../venues' import type { Env } from '../lend/defindex/config' import { VAULT_KEYS } from '../lend/defindex/contracts' import { readDefindexRate, type DefindexRate } from '../lend/defindex/rate' @@ -388,15 +388,22 @@ export function buildMarketContext( // // No chain means no venues. An agent with nothing to choose from is a // visible failure; an agent choosing Uniswap on Stellar is an invisible one. - const family = isChainSlug(chain) ? CHAIN_DESCRIPTORS[chain].family : undefined + const descriptor = isChainSlug(chain) ? CHAIN_DESCRIPTORS[chain] : undefined + const family = descriptor?.family // A lending venue this deployment cannot reach is absent, not listed and // failing later. DeFindex needs a key; without one it does not exist here. const lendingOffered: string[] = configuredLendingVenues(env, network) - // Solana, Sui and NEAR have their own venues and tokens. The reference - // prices and the asset list below describe Stellar, and an agent shown them - // would reason about tokens that do not exist on its chain. - const ownChain = family === 'solana' || family === 'sui' || family === 'near' + // Solana, Sui, NEAR, Avalanche and Monad have their own venues and tokens. The + // reference prices and the asset list below describe Stellar, and an agent + // shown them would reason about tokens that do not exist on its chain. Arc + // shares a family with the last two but has no module, so it stays as it was. + const ownChain = + family === 'solana' || + family === 'sui' || + family === 'near' || + chain === 'avalanche' || + chain === 'monad' return { asOf: new Date().toISOString(), @@ -421,7 +428,7 @@ export function buildMarketContext( // failure for a model, so the list it may choose from is filtered here // rather than validated after the fact. venues: venues - .filter((v) => family !== undefined && v.family === family) + .filter((v) => descriptor !== undefined && venueOnChain(v, descriptor)) // And on this network. A Stellar venue whose mainnet contracts are not // verified is testnet-only, and an agent must not be offered it. .filter((v) => v.family !== 'stellar' || isVenueOn(v, network)) diff --git a/apps/dapp/lib/chains/capabilities.ts b/apps/dapp/lib/chains/capabilities.ts index 4da11184..aa6d1fa2 100644 --- a/apps/dapp/lib/chains/capabilities.ts +++ b/apps/dapp/lib/chains/capabilities.ts @@ -1,5 +1,6 @@ import type { ChainSlug } from '@intent/types' +import { avalancheCapabilities, monadCapabilities } from './evm/capabilities' import type { ChainCapability } from './explain' import { nearCapabilities } from './near/capabilities' import { solanaCapabilities } from './solana/capabilities' @@ -10,5 +11,7 @@ export function chainCapabilities(slug: ChainSlug): readonly ChainCapability[] | if (slug === 'solana') return solanaCapabilities if (slug === 'sui') return suiCapabilities if (slug === 'near') return nearCapabilities + if (slug === 'avalanche') return avalancheCapabilities + if (slug === 'monad') return monadCapabilities return undefined } diff --git a/apps/dapp/lib/chains/evm/capabilities.ts b/apps/dapp/lib/chains/evm/capabilities.ts new file mode 100644 index 00000000..1a5ef115 --- /dev/null +++ b/apps/dapp/lib/chains/evm/capabilities.ts @@ -0,0 +1,10 @@ +import type { ChainCapability } from '../explain' + +/** What actually works here, for telling someone who asked for something else. Client-safe. */ +export const avalancheCapabilities: ChainCapability[] = [ + { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'AVAX' }, +] + +export const monadCapabilities: ChainCapability[] = [ + { kind: 'swap', venue: 'NEAR Intents 1Click', symbol: 'MON' }, +] diff --git a/apps/dapp/lib/chains/evm/client.ts b/apps/dapp/lib/chains/evm/client.ts new file mode 100644 index 00000000..55ecd097 --- /dev/null +++ b/apps/dapp/lib/chains/evm/client.ts @@ -0,0 +1,49 @@ +import { + createPublicClient, + erc20Abi, + http, + type Address, + type Hash, + type TransactionReceipt, + type Transport, +} from 'viem' + +import { EVM_CHAINS, type EvmChain } from './tokens' + +const RPC_TIMEOUT_MS = 8_000 +/** Well inside the submit route's 60 seconds, which still has 1Click to wait on afterwards. */ +const RECEIPT_TIMEOUT_MS = 20_000 + +export type EvmReceipt = Pick + +export interface EvmClient { + tokenBalance: (token: Address, account: Address) => Promise + nativeBalance: (account: Address) => Promise + waitForReceipt: (hash: Hash) => Promise + /** A receipt doesn't carry what a transaction paid, so this is read apart from it. */ + transactionValue: (hash: Hash) => Promise +} + +/** Reads this chain only, so a hash from another chain isn't found here. */ +export function evmClient(chain: EvmChain, transport?: Transport): EvmClient { + const { chain: viemChain, rpcEnv } = EVM_CHAINS[chain] + const client = createPublicClient({ + chain: viemChain, + transport: + transport ?? + http(process.env[rpcEnv]?.trim() || undefined, { timeout: RPC_TIMEOUT_MS, retryCount: 1 }), + }) + return { + tokenBalance: (token, account) => + client.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account], + }), + nativeBalance: (address) => client.getBalance({ address }), + waitForReceipt: (hash) => + client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }), + transactionValue: async (hash) => (await client.getTransaction({ hash })).value, + } +} diff --git a/apps/dapp/lib/chains/evm/deposit.ts b/apps/dapp/lib/chains/evm/deposit.ts new file mode 100644 index 00000000..93d2a43d --- /dev/null +++ b/apps/dapp/lib/chains/evm/deposit.ts @@ -0,0 +1,198 @@ +import { + decodeFunctionData, + encodeFunctionData, + isAddress, + parseAbi, + zeroAddress, + type Address, + type Hex, +} from 'viem' + +import type { DepositChain, DepositExpectation } from '../../weave/types' +import { formatUnits } from '../near/tokens' +import { + ChainActionRefused, + toBaseUnits, + withArticle, + type ChainToken, + type SigningPayload, +} from '../types' +import { evmClient, type EvmClient } from './client' +import { confirmEvmTransfer, sameAddress } from './submit' +import { EVM_CHAINS, EVM_NATIVE_ID, type EvmChain } from './tokens' + +const TRANSFER_ABI = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']) + +export interface EvmDeposit extends Pick< + DepositChain, + 'isAccount' | 'isDepositAddress' | 'build' | 'assertSafe' | 'submit' +> { + /** Refuses a swap this account can't pay for, with its gas. Fails closed if it can't be read. */ + assertFunds: (account: string, token: ChainToken, amount: string) => Promise +} + +function refuse(reason: string): never { + throw new ChainActionRefused(`${reason} Nothing was signed or sent.`) +} + +function unitsOf(amount: string, token: ChainToken): bigint { + try { + return BigInt(toBaseUnits(amount, token.decimals)) + } catch (e) { + return refuse(e instanceof Error ? e.message : 'That is not an amount.') + } +} + +/** + * One transfer on Avalanche or Monad: the native coin to the deposit address, or one ERC-20 + * `transfer` to it. The token always comes from the order and is never assumed, because a proof + * naming USDT must not be able to build or pass as USDC. + */ +export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmDeposit { + const { name, chain: viemChain, tokens, nativeSymbol, gasReserve } = EVM_CHAINS[chain] + + function isAccount(address: string): boolean { + return isAddress(address, { strict: false }) && !sameAddress(address, zeroAddress) + } + + function isDepositAddress(address: string): boolean { + return ( + isAccount(address) && + !tokens.some((token) => token.id !== EVM_NATIVE_ID && sameAddress(address, token.id)) + ) + } + + function tokenOf(expectation: DepositExpectation): ChainToken { + const token = tokens.find((t) => t.id === expectation.token?.id) + return token ?? refuse(`That token isn't one this app moves on ${name}.`) + } + + function assertOrder(expectation: DepositExpectation): ChainToken { + if (expectation.memo !== undefined) refuse(`${name} cannot carry the memo this order needs.`) + if (!isDepositAddress(expectation.depositAddress)) { + refuse('This order’s deposit address cannot take a deposit.') + } + return tokenOf(expectation) + } + + function assertSafe(payload: SigningPayload, expectation: DepositExpectation): void { + if (payload.family !== 'evm') return refuse(`That is not ${withArticle(name)} transaction.`) + const token = assertOrder(expectation) + if (payload.chainId !== viemChain.id) refuse(`That transaction is not for ${name}.`) + const units = unitsOf(expectation.amount, token) + + if (token.id === EVM_NATIVE_ID) { + if (!sameAddress(payload.to, expectation.depositAddress)) { + refuse('That transfer is not to this order’s deposit address.') + } + if (payload.value !== units.toString()) { + refuse('That transfer’s amount is not this order’s amount.') + } + if (payload.data !== '0x') refuse('A transfer of the native coin carries no data.') + return + } + + if (!sameAddress(payload.to, token.id)) { + refuse(`That transaction is not a call to the ${token.symbol} contract.`) + } + if (payload.value !== '0') refuse(`That transaction also spends ${nativeSymbol} as value.`) + // Decoded against `transfer` alone, so any other selector cannot decode at all. + let args: readonly [Address, bigint] + try { + args = decodeFunctionData({ abi: TRANSFER_ABI, data: payload.data as Hex }).args + } catch { + return refuse(`That transaction is not a ${token.symbol} transfer.`) + } + const [recipient, sent] = args + if (!sameAddress(recipient, expectation.depositAddress)) { + refuse('That transfer is not to this order’s deposit address.') + } + if (sent !== units) refuse('That transfer’s amount is not this order’s amount.') + // The decoder ignores what comes after the arguments and above an address's 20 bytes. + const canonical = encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [recipient, sent], + }) + if (!sameAddress(canonical, payload.data)) { + refuse('That transaction carries extra data, and must be exactly one transfer.') + } + } + + return { + isAccount, + isDepositAddress, + assertSafe, + + assertFunds: async (account, token, amount) => { + const units = unitsOf(amount, token) + const reserve = BigInt(gasReserve) + const kept = `${formatUnits(gasReserve, 18)} ${nativeSymbol}` + const native = token.id === EVM_NATIVE_ID + let held: bigint | undefined + let coin: bigint + try { + const reader = client() + ;[held, coin] = await Promise.all([ + native ? undefined : reader.tokenBalance(token.id as Address, account as Address), + reader.nativeBalance(account as Address), + ]) + } catch { + return refuse(`Could not read this account’s balances from ${name}.`) + } + if (native) { + if (coin < units + reserve) { + refuse( + `This account needs ${amount} ${nativeSymbol} for the swap and about ${kept} more for the network fee, and holds less than that.` + ) + } + return + } + if (held === undefined || held < units) { + refuse(`This account holds less ${token.symbol} than this swap needs.`) + } + if (coin < reserve) { + refuse( + `This account has too little ${nativeSymbol} for the network fee. Keep about ${kept}.` + ) + } + }, + + build: async (expectation) => { + const token = assertOrder(expectation) + if (!isAccount(expectation.account)) refuse(`That is not ${withArticle(name)} account.`) + const units = unitsOf(expectation.amount, token) + const payload: SigningPayload = + token.id === EVM_NATIVE_ID + ? { + family: 'evm', + chainId: viemChain.id, + to: expectation.depositAddress, + data: '0x', + value: units.toString(), + } + : { + family: 'evm', + chainId: viemChain.id, + to: token.id, + data: encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [expectation.depositAddress as Address, units], + }), + value: '0', + } + assertSafe(payload, expectation) + return payload + }, + + submit: (signed, expectation) => confirmEvmTransfer(chain, client, signed, expectation), + } +} + +const shared: Partial> = {} + +/** Takes the client as a function so nothing connects until a deposit is actually read. */ +export function evmDeposit(chain: EvmChain): EvmDeposit { + return createEvmDeposit(chain, () => (shared[chain] ??= evmClient(chain))) +} diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts new file mode 100644 index 00000000..8f45510c --- /dev/null +++ b/apps/dapp/lib/chains/evm/module.ts @@ -0,0 +1,52 @@ +import { oneClickPrice } from '../near/oneclick' +import { canIssueOneClick, oneClickAssetId, oneClickOriginVenue } from '../oneclick-origin' +import type { ChainModule, ChainToken, ChainVenue } from '../types' +import { EVM_CHAINS, type EvmChain } from './tokens' + +export function buildEvmVenues( + chain: EvmChain, + env: Record = process.env +): ChainVenue[] { + return canIssueOneClick(env) ? [oneClickOriginVenue(chain)] : [] +} + +/** + * Priced from 1Click's list, as NEAR is. Every route here comes from that one venue, so one source + * can't rank two routes by a gap between sources. Any doubt is no price, which refuses the trade. + */ +async function priceFromOneClick(chain: EvmChain, token: ChainToken): Promise { + try { + const assetId = await oneClickAssetId(chain, token) + return assetId === undefined ? undefined : await oneClickPrice(assetId, token.decimals) + } catch { + return undefined + } +} + +function evmModule(chain: EvmChain, tokenHints?: Record): ChainModule { + return { + chain, + tokens: EVM_CHAINS[chain].tokens, + venues: buildEvmVenues(chain), + defaultVenue: { swap: `1click-${chain}` }, + priceUsd: (token) => priceFromOneClick(chain, token), + // Every venue here lands its own transactions, and no venue means no submit gets this far. + submit: async () => ({ + ok: false, + pending: true, + error: + "This app can't follow that from here. Check your wallet's activity before trying again.", + }), + ...(tokenHints !== undefined ? { tokenHints } : {}), + } +} + +export const avalancheModule = evmModule('avalanche') + +/** + * USDT is not matched to USDT0. A person is told instead, so nothing gets signed under a name they + * didn't type, and the one token keeps the one name on the card, in the wallet and in 1Click's list. + */ +export const monadModule = evmModule('monad', { + USDT: 'On Monad, Tether’s dollar is called USDT0, so ask for USDT0.', +}) diff --git a/apps/dapp/lib/chains/evm/submit.ts b/apps/dapp/lib/chains/evm/submit.ts new file mode 100644 index 00000000..ff236a74 --- /dev/null +++ b/apps/dapp/lib/chains/evm/submit.ts @@ -0,0 +1,98 @@ +import { erc20Abi, parseEventLogs, type Hash } from 'viem' + +import type { DepositExpectation } from '../../weave/types' +import { toBaseUnits, type SignedPayload, type SubmitResult } from '../types' +import type { EvmClient, EvmReceipt } from './client' +import { EVM_CHAINS, EVM_NATIVE_ID, evmExplorerUrl, type EvmChain } from './tokens' + +export const EVM_HASH = /^0x[0-9a-fA-F]{64}$/ + +export function sameAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +/** Whether this receipt shows this account paying exactly this token transfer to this address. */ +export function erc20TransferPaid( + receipt: EvmReceipt, + paid: { token: string; account: string; depositAddress: string; units: bigint } +): boolean { + if (!sameAddress(receipt.from, paid.account)) return false + if (receipt.to === null || !sameAddress(receipt.to, paid.token)) return false + return parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs }).some( + (log) => + sameAddress(log.address, paid.token) && + sameAddress(log.args.from, paid.account) && + sameAddress(log.args.to, paid.depositAddress) && + log.args.value === paid.units + ) +} + +function pending(error: string): SubmitResult { + return { ok: false, pending: true, error } +} + +/** + * Asks the chain what the wallet sent. The wallet broadcast this for itself, so nothing here can + * stop it, and an answer may lack `pending` only if it proves the funds did not move. Only a + * revert does, and only from this account to the address this transfer was meant for: a browser + * could hand in any failed transaction while the real transfer went out. + * + * A receipt that is someone else's or for something else says nothing about the real transfer, + * so it is pending too, and it is never reported to 1Click. + */ +export async function confirmEvmTransfer( + chain: EvmChain, + client: () => EvmClient, + signed: SignedPayload, + expectation: DepositExpectation +): Promise { + const { name } = EVM_CHAINS[chain] + const { account, depositAddress, amount, token } = expectation + if (signed.family !== 'evm' || !EVM_HASH.test(signed.hash) || token === undefined) { + return pending( + "The wallet didn't return a transaction this app can follow. Check your wallet's activity before trying again, in case it was sent." + ) + } + const { hash } = signed + const notConfirmed = pending( + `This transfer isn't confirmed on ${name} yet, and it may still land, so don't send it again. Its transaction is ${hash}.` + ) + const notThisSwap = pending( + `Transaction ${hash} is not this swap's transfer, so it was not reported to NEAR Intents. Check your wallet's activity before trying again, in case the real one went out.` + ) + + let receipt: EvmReceipt + try { + receipt = await client().waitForReceipt(hash as Hash) + } catch { + // What a hash from another chain gives too: this chain never finds it. + return notConfirmed + } + + const native = token.id === EVM_NATIVE_ID + const expectedTo = native ? depositAddress : token.id + const fromThisAccountToThatAddress = + sameAddress(receipt.from, account) && receipt.to !== null && sameAddress(receipt.to, expectedTo) + if (!fromThisAccountToThatAddress) return notThisSwap + + if (receipt.status === 'reverted') { + return { + ok: false, + error: `This transfer failed on ${name}. The ${token.symbol} did not move, and only the gas was spent.`, + } + } + + const units = BigInt(toBaseUnits(amount, token.decimals)) + if (native) { + let value: bigint + try { + value = await client().transactionValue(hash as Hash) + } catch { + return notConfirmed + } + if (value !== units) return notThisSwap + } else if (!erc20TransferPaid(receipt, { token: token.id, account, depositAddress, units })) { + return notThisSwap + } + return { ok: true, hash, explorerUrl: evmExplorerUrl(chain, hash) } +} diff --git a/apps/dapp/lib/chains/evm/tokens.ts b/apps/dapp/lib/chains/evm/tokens.ts new file mode 100644 index 00000000..fc766b58 --- /dev/null +++ b/apps/dapp/lib/chains/evm/tokens.ts @@ -0,0 +1,89 @@ +import { avalanche, monad } from 'viem/chains' +import type { Chain } from 'viem' + +import type { ChainToken } from '../types' + +/** + * Avalanche and Monad, which swap only through 1Click. Each contract here is matched against + * 1Click's list by `oneClickAssetId` on chain, contract and decimals, so a wrong entry finds no + * route rather than a wrong token. + */ + +export type EvmChain = 'avalanche' | 'monad' + +/** Stands for the chain's own coin, which has no contract. Not an address, so it can't be called. */ +export const EVM_NATIVE_ID = 'native' + +export const AVALANCHE_TOKENS: readonly ChainToken[] = [ + { symbol: 'AVAX', id: EVM_NATIVE_ID, decimals: 18, name: 'Avalanche' }, + // Circle's native USDC on the C-Chain, not the bridged USDC.e. + { + symbol: 'USDC', + id: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', + decimals: 6, + name: 'USD Coin', + }, + // Tether's native USDt on the C-Chain, not the bridged USDT.e. + { + symbol: 'USDT', + id: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7', + decimals: 6, + name: 'Tether USD', + }, +] + +export const MONAD_TOKENS: readonly ChainToken[] = [ + { symbol: 'MON', id: EVM_NATIVE_ID, decimals: 18, name: 'Monad' }, + { + symbol: 'USDC', + id: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', + decimals: 6, + name: 'USD Coin', + }, + { + symbol: 'USDT0', + id: '0xe7cd86e13AC4309349F30B3435a9d337750fC82D', + decimals: 6, + name: 'USDT0', + }, +] + +export interface EvmChainConfig { + name: string + chain: Chain + tokens: readonly ChainToken[] + nativeSymbol: string + /** + * Base units of the native coin kept for gas, and asked of anyone paying a token instead. Well + * over one transfer on an ordinary day and not a quote: it only decides whether to refuse early, + * because the wallet sets the real fee. + */ + gasReserve: string + rpcEnv: string + explorer: string +} + +export const EVM_CHAINS: Record = { + avalanche: { + name: 'Avalanche', + chain: avalanche, + tokens: AVALANCHE_TOKENS, + nativeSymbol: 'AVAX', + gasReserve: '5000000000000000', + rpcEnv: 'AVALANCHE_RPC_URL', + explorer: 'https://snowtrace.io', + }, + monad: { + name: 'Monad', + chain: monad, + tokens: MONAD_TOKENS, + nativeSymbol: 'MON', + gasReserve: '50000000000000000', + rpcEnv: 'MONAD_RPC_URL', + explorer: 'https://monadvision.com', + }, +} + +export function evmExplorerUrl(chain: EvmChain, hash: string): string { + return `${EVM_CHAINS[chain].explorer}/tx/${hash}` +} diff --git a/apps/dapp/lib/chains/evm/venues.ts b/apps/dapp/lib/chains/evm/venues.ts new file mode 100644 index 00000000..bfdd94e7 --- /dev/null +++ b/apps/dapp/lib/chains/evm/venues.ts @@ -0,0 +1,26 @@ +import type { Venue } from '@intent/types' + +const ONECLICK_DOCS = + 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api' + +// Listed, as Lulo is on Solana: whether this runs depends on a secret this client-safe file can't read. +export const evmVenues: Venue[] = [ + { + id: '1click-avalanche', + family: 'evm', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Avalanche'], + url: ONECLICK_DOCS, + bestFor: 'Swaps between AVAX, USDC and USDT, priced by competing market makers', + }, + { + id: '1click-monad', + family: 'evm', + name: 'NEAR Intents 1Click', + category: 'swap', + chains: ['Monad'], + url: ONECLICK_DOCS, + bestFor: 'Swaps between MON, USDC and USDT0, priced by competing market makers', + }, +] diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts index 3df6bbbd..c1e62cda 100644 --- a/apps/dapp/lib/chains/oneclick-origin.ts +++ b/apps/dapp/lib/chains/oneclick-origin.ts @@ -3,9 +3,13 @@ import { createHmac, timingSafeEqual } from 'node:crypto' import { TransactionDataBuilder } from '@mysten/sui/transactions' import { fromBase64, normalizeStructTag } from '@mysten/sui/utils' +import { walletSends } from '../weave/cashout-view' import { solanaDeposit } from '../weave/deposit/solana' import { suiDeposit } from '../weave/deposit/sui' import type { DepositChain, DepositExpectation } from '../weave/types' +import { evmDeposit } from './evm/deposit' +import { EVM_HASH, sameAddress } from './evm/submit' +import { EVM_CHAINS, EVM_NATIVE_ID, evmExplorerUrl, type EvmChain } from './evm/tokens' import { depositStatus, listTokens, notifyDeposit, type OneClickToken } from './near/oneclick' import { assertNoDepositMemo, @@ -26,6 +30,7 @@ import { SUI_TOKENS } from './sui/tokens' import { ChainActionRefused, tokenBySymbol, + withArticle, type ChainActionRequest, type ChainToken, type ChainVenue, @@ -57,7 +62,7 @@ const BLOCKCHAIN: Record = { monad: 'monad', } -/** Only these are spent through 1Click in this app so far. Native coins go through the chain's own swap. */ +/** All that's spent through 1Click on a chain with a swap of its own, which takes the native coins. */ const SPENDABLE = new Set(['USDC', 'USDT']) /** @@ -87,7 +92,7 @@ const SOLANA_RENT_NOTE = interface Origin { chainName: string - family: 'solana' | 'sui' + family: 'solana' | 'sui' | 'evm' tokens: readonly ChainToken[] /** The token id that stands for the native coin, which 1Click lists with no contract. */ nativeId: string @@ -98,8 +103,11 @@ interface Origin { nativeIdIsTheCoin: boolean /** What the wallet pays beyond the transfer and the fee, with the sentence that says so. */ walletCost?: { amount: string; symbol: string; note: string } - ownSwap: string - deposit: DepositChain + /** The chain's own swap, which takes what 1Click doesn't here. Absent where 1Click is the only one. */ + ownSwap?: string + deposit: Pick + /** Reads what the wallet holds before a deposit address is asked for, where the build won't. */ + assertFunds?: (account: string, token: ChainToken, amount: string) => Promise /** The chain's identifier for signed bytes, known before the network has answered. */ hashOf: (signed: SignedPayload) => string | undefined explorerUrl: (hash: string) => string @@ -109,7 +117,26 @@ function suiExplorerUrl(hash: string): string { return `https://suiscan.xyz/mainnet/tx/${hash}` } -const ORIGINS: Partial> = { +function evmOrigin(chain: EvmChain): Origin { + const deposit = evmDeposit(chain) + const { name, tokens } = EVM_CHAINS[chain] + return { + chainName: name, + family: 'evm', + tokens, + nativeId: EVM_NATIVE_ID, + nativeIdIsTheCoin: false, + deposit, + assertFunds: deposit.assertFunds, + hashOf: (signed) => + signed.family === 'evm' && EVM_HASH.test(signed.hash) ? signed.hash : undefined, + explorerUrl: (hash) => evmExplorerUrl(chain, hash), + } +} + +const ORIGINS: Record = { + avalanche: evmOrigin('avalanche'), + monad: evmOrigin('monad'), solana: { chainName: 'Solana', family: 'solana', @@ -176,7 +203,7 @@ export async function oneClickAssetId( const origin = ORIGINS[chain] const matching = - token.id === origin?.nativeId + token.id === origin.nativeId ? entries.filter( (entry) => entry.symbol.toUpperCase() === token.symbol.toUpperCase() && @@ -244,12 +271,18 @@ function notIssued(): never { ) } -/** The deposit this server issued, or a refusal. Never returns anything it did not sign. */ +/** + * The deposit this server issued, or a refusal. Never returns anything it did not sign. + * + * `deadlineMatters` is false once a wallet has sent for itself: an expired quote then can't mean + * nothing was sent, so the transfer is followed and 1Click says what became of it. + */ function readIssued( origin: Origin, venueId: string, account: string, - context: Record | undefined + context: Record | undefined, + deadlineMatters = true ): DepositExpectation { const key = secret() const depositAddress = context?.['depositAddress'] @@ -273,7 +306,7 @@ function readIssued( } const token = origin.tokens.find((t) => t.id === tokenId) if (token === undefined) return notIssued() - if (!(Date.parse(deadline) > Date.now())) { + if (deadlineMatters && !(Date.parse(deadline) > Date.now())) { throw new ChainActionRefused( 'This quote expired before it was sent, so nothing was sent. Get a fresh quote and sign right away.' ) @@ -287,14 +320,6 @@ function readIssued( } } -function originOf(chain: OneClickOriginChain): Origin { - const origin = ORIGINS[chain] - if (origin === undefined) { - throw new ChainActionRefused(`${VENUE_NAME} can't swap on this chain yet.`) - } - return origin -} - interface Route { tokenIn: ChainToken tokenOut: ChainToken @@ -313,7 +338,7 @@ async function routeFor( throw new ChainActionRefused(`${VENUE_NAME} swaps tokens. It can't ${request.kind} here.`) } if (!origin.deposit.isAccount(account)) { - throw new ChainActionRefused(`That is not a ${origin.chainName} wallet address.`) + throw new ChainActionRefused(`That is not ${withArticle(origin.chainName)} wallet address.`) } const tokenIn = tokenBySymbol(origin.tokens, request.tokenIn) const tokenOut = tokenBySymbol(origin.tokens, request.tokenOut ?? '') @@ -321,7 +346,7 @@ async function routeFor( throw new ChainActionRefused(`I can only swap tokens I have confirmed on ${origin.chainName}.`) } if (tokenIn.id === tokenOut.id) throw new ChainActionRefused('Those are the same token.') - if (!SPENDABLE.has(tokenIn.symbol)) { + if (origin.ownSwap !== undefined && !SPENDABLE.has(tokenIn.symbol)) { throw new ChainActionRefused( `${VENUE_NAME} here spends USDC or USDT, not ${tokenIn.symbol}. ${origin.ownSwap} can swap ${tokenIn.symbol}.` ) @@ -370,7 +395,8 @@ function delay(ms: number): Promise { /** * Sends through the chain's own deposit submit, which checks the bytes again first. Its * refusals only ever come before the send, so they prove nothing moved. Anything else thrown, - * or no answer inside the limit, leaves open that the transfer went out. + * or no answer inside the limit, leaves open that the transfer went out. So does any refusal + * where the wallet sent for itself, since there was no send here for it to come before. */ async function send( origin: Origin, @@ -395,7 +421,9 @@ async function send( (await Promise.race([origin.deposit.submit(signed, expectation), limit])) ?? unconfirmed() ) } catch (e) { - return e instanceof ChainActionRefused ? { ok: false, error: e.message } : unconfirmed() + return e instanceof ChainActionRefused && !walletSends(origin.family) + ? { ok: false, error: e.message } + : unconfirmed() } finally { clearTimeout(timer) } @@ -405,8 +433,9 @@ async function send( export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { const venueId = `1click-${chain}` + const origin = ORIGINS[chain] + async function quote(request: ChainActionRequest, account: string): Promise { - const origin = originOf(chain) const route = await routeFor(origin, chain, request, account) signingKey() const { preview } = await previewOneClick( @@ -429,7 +458,6 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { } async function prepare(request: ChainActionRequest, account: string): Promise { - const origin = originOf(chain) const { tokenIn, tokenOut, @@ -437,6 +465,8 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { destination, } = await routeFor(origin, chain, request, account) const key = signingKey() + // Before the real quote, so someone who can't pay never gets a deposit address made. + await origin.assertFunds?.(account, tokenIn, request.amount) const { shared } = await previewOneClick(request.amount, spending, destination, account) const { live, loss, deadline } = await liveOneClick(shared, spending, destination) @@ -484,7 +514,6 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { account: string, context?: Record ): void { - const origin = originOf(chain) origin.deposit.assertSafe(payload, readIssued(origin, venueId, account, context)) } @@ -493,21 +522,38 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { * the chain's own submit having proved it did not move. Once the network has the transfer, * a swap that fails at 1Click still has funds in flight or on their way back, so that is * always pending and the latch stays closed. + * + * A wallet that sends for itself has already sent by the time anything arrives here. So on + * those chains a refusal below can't say nothing was sent, and it is pending too. */ async function submit(input: SubmitInput): Promise { const { account, signed, context } = input - const origin = originOf(chain) - if (signed.family !== origin.family) { + const sends = walletSends(origin.family) + const notFollowed = (why: string): SubmitResult => { + const hash = origin.hashOf(signed) return { ok: false, - error: `That is not a ${origin.chainName} transaction, so nothing was sent.`, + pending: true, + error: `${why} so it can't be followed from here. Your wallet may already have sent it${hash === undefined ? '' : ` as ${hash}`}, so check its activity before trying again.`, } } + if (signed.family !== origin.family) { + return sends + ? notFollowed(`That is not ${withArticle(origin.chainName)} transaction,`) + : { + ok: false, + error: `That is not ${withArticle(origin.chainName)} transaction, so nothing was sent.`, + } + } let expectation: DepositExpectation try { - expectation = readIssued(origin, venueId, account, context) + expectation = readIssued(origin, venueId, account, context, !sends) } catch (e) { - if (e instanceof ChainActionRefused) return { ok: false, error: e.message } + if (e instanceof ChainActionRefused) { + return sends + ? notFollowed("This swap's details are not what this app issued,") + : { ok: false, error: e.message } + } throw e } const { depositAddress } = expectation @@ -520,17 +566,21 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { : sent } - const { hash } = sent - await notifyDeposit(depositAddress, hash) + const { hash: sentHash } = sent + await notifyDeposit(depositAddress, sentHash) + + // A hex address may come back in another casing, and on other chains case is part of it. + const isAccount = (recipient: string): boolean => + origin.family === 'evm' ? sameAddress(recipient, account) : recipient === account while (Date.now() - startedAt < STATUS_WAIT_MS) { const body = await depositStatus(depositAddress) const recipient = body?.quoteResponse?.quoteRequest?.recipient - if (recipient !== undefined && recipient !== account) { + if (recipient !== undefined && !isAccount(recipient)) { return { ok: false, pending: true, - error: `That deposit address belongs to a different swap. Your transfer is ${hash}. Check your wallet's activity before trying again.`, + error: `That deposit address belongs to a different swap. Your transfer is ${sentHash}. Check your wallet's activity before trying again.`, } } switch (body?.status) { @@ -546,7 +596,7 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { return { ok: false, pending: true, - error: `The swap failed after your deposit (${hash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`, + error: `The swap failed after your deposit (${sentHash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`, } case 'INCOMPLETE_DEPOSIT': return { @@ -558,7 +608,7 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { await delay(STATUS_POLL_MS) } } - return stillProcessing(hash, depositAddress) + return stillProcessing(sentHash, depositAddress) } return { diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index f96660e2..032bf56d 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -40,10 +40,14 @@ function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVen * a refused token reads the same in each and no agent is ever consulted about it. */ export function assertKnownTokens(chainApi: ChainModule, request: ChainActionRequest): ChainToken { + const hintFor = (symbol: string | undefined): string => { + const hint = chainApi.tokenHints?.[(symbol ?? '').trim().toUpperCase()] + return hint === undefined ? '' : ` ${hint}` + } const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn) if (tokenIn === undefined) { throw new ChainActionRefused( - `I can't confirm ${request.tokenIn} as a verified token on this chain, so I won't touch it.` + `I can't confirm ${request.tokenIn} as a verified token on this chain, so I won't touch it.${hintFor(request.tokenIn)}` ) } if (request.kind === 'swap') { @@ -52,7 +56,7 @@ export function assertKnownTokens(chainApi: ChainModule, request: ChainActionReq tokenBySymbol(chainApi.tokens, request.tokenOut) === undefined ) { throw new ChainActionRefused( - `I can't confirm ${request.tokenOut ?? 'that token'} as a verified token on this chain, so I won't trade it.` + `I can't confirm ${request.tokenOut ?? 'that token'} as a verified token on this chain, so I won't trade it.${hintFor(request.tokenOut)}` ) } if (request.tokenOut.toUpperCase() === request.tokenIn.toUpperCase()) { diff --git a/apps/dapp/lib/chains/registry.ts b/apps/dapp/lib/chains/registry.ts index 9df5fb47..c7473cc6 100644 --- a/apps/dapp/lib/chains/registry.ts +++ b/apps/dapp/lib/chains/registry.ts @@ -1,3 +1,4 @@ +import { avalancheModule, monadModule } from './evm/module' import { nearModule } from './near/module' import { solanaModule } from './solana/module' import { suiModule } from './sui/module' @@ -7,6 +8,8 @@ const MODULES: Record = { solana: solanaModule, sui: suiModule, near: nearModule, + avalanche: avalancheModule, + monad: monadModule, } export function chainModule(chain: string): ChainModule | undefined { diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index 52fb42ce..b52a1e6a 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -1,4 +1,4 @@ -import type { SignedOutcome, SignedPayload, SigningPayload } from './types' +import type { PreparedAction, SignedOutcome, SignedPayload, SigningPayload } from './types' import { CHECK_WALLET, accountMismatch, @@ -20,6 +20,24 @@ export function latchBeforeSign(family: Family): boolean { return walletSends(family) } +export const QUOTE_EXPIRED = + 'This quote expired before you signed, so your wallet was not asked and nothing was sent. Press Review again for a fresh one.' + +/** + * Whether a card has outlived its quote, on a chain where this is the last chance to say so. The + * server checks the deadline before it sends signed bytes, but an EVM wallet sends for itself, and + * a late deposit may be refunded instead of swapped. NEAR's wallet sends too, and is left as it was. + */ +export function quoteExpiredBeforeSigning( + prepared: Pick, + now: number = Date.now() +): boolean { + const deadline = prepared.context?.['deadline'] + return ( + prepared.signing.family === 'evm' && deadline !== undefined && !(Date.parse(deadline) > now) + ) +} + export type AfterSigning = | { next: 'submit'; signed: SignedPayload } /** Signed-bytes wallet declined: nothing left the device, so the card stays on offer. */ diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts index 31bb9f6c..081f448a 100644 --- a/apps/dapp/lib/chains/types.ts +++ b/apps/dapp/lib/chains/types.ts @@ -6,7 +6,7 @@ import type { ChainSlug } from '@intent/types' * routes, the hook and the chat card know nothing about any chain in particular. */ -export type ChainWithActions = Extract +export type ChainWithActions = Extract export type ChainActionKind = 'swap' | 'supply' | 'stake' @@ -29,12 +29,12 @@ export interface NearTransaction { }[] } -/** What the wallet is asked to sign. Base64 for Solana and Sui. */ +/** What the wallet is asked to sign. Base64 for Solana and Sui. An EVM `value` is decimal base units. */ export type SigningPayload = | { family: 'solana'; transaction: string } | { family: 'sui'; transaction: string } | { family: 'near'; transactions: NearTransaction[] } - | { family: 'evm'; chainId: number; to: string; data: string; value: '0' } + | { family: 'evm'; chainId: number; to: string; data: string; value: string } | { family: 'stellar'; xdr: string; account: string } /** @@ -147,6 +147,13 @@ export interface ChainModule { priceUsd: (token: ChainToken) => Promise /** Sends signed bytes, or records what a wallet already sent. */ submit: (input: SubmitInput) => Promise + /** A sentence for a symbol this chain doesn't list but a person may well type, by upper-case symbol. */ + tokenHints?: Record | undefined +} + +/** "an Avalanche", "a Monad". */ +export function withArticle(name: string): string { + return `${/^[AEIOU]/.test(name) ? 'an' : 'a'} ${name}` } /** diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index 20bbd57d..7386a195 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -16,7 +16,7 @@ import { chainCapabilities } from '../chains/capabilities' * once its venues can be quoted, so the sheet never promises a race that does * not run. */ -const AGENT_RACE_CHAINS = ['stellar', 'near', 'solana', 'sui'] +const AGENT_RACE_CHAINS = ['stellar', 'near', 'solana', 'sui', 'avalanche', 'monad'] const SWAP_RACED = 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.' diff --git a/apps/dapp/lib/server/analytics.ts b/apps/dapp/lib/server/analytics.ts index 4f6ed76a..28baf98a 100644 --- a/apps/dapp/lib/server/analytics.ts +++ b/apps/dapp/lib/server/analytics.ts @@ -110,6 +110,8 @@ export type ExecutionKind = | 'solana' | 'sui' | 'near' + | 'avalanche' + | 'monad' export interface ExecutionRecord { network: StellarNetworkName diff --git a/apps/dapp/lib/venues.ts b/apps/dapp/lib/venues.ts index 6bba43be..a3165377 100644 --- a/apps/dapp/lib/venues.ts +++ b/apps/dapp/lib/venues.ts @@ -1,6 +1,7 @@ import { activeNetwork, type StellarNetworkName } from '@intent/config' -import type { Venue } from '@intent/types' +import type { ChainDescriptor, Venue } from '@intent/types' +import { evmVenues } from './chains/evm/venues' import { nearVenues } from './chains/near/venues' import { solanaVenues } from './chains/solana/venues' import { suiVenues } from './chains/sui/venues' @@ -10,6 +11,7 @@ export const venues: Venue[] = [ ...solanaVenues, ...suiVenues, ...nearVenues, + ...evmVenues, { id: 'cow-swap', family: 'evm' as const, @@ -323,6 +325,24 @@ export const venues: Venue[] = [ }, ] +const EVM_CHAINS_WITH_VENUES = ['Avalanche', 'Monad'] + +/** + * Whether a venue belongs on this chain's lists. Family decides, except that EVM chains share one: + * Avalanche and Monad list only the venues that name them, and a venue naming either one belongs + * to that chain alone. Arc has no venues of its own, so it keeps the rest of the family's list. + */ +export function venueOnChain( + venue: Pick, + chain: Pick +): boolean { + if (venue.family !== chain.family) return false + if (chain.family !== 'evm') return true + return EVM_CHAINS_WITH_VENUES.includes(chain.name) + ? venue.chains.includes(chain.name) + : !venue.chains.some((name) => EVM_CHAINS_WITH_VENUES.includes(name)) +} + /** * Which venues exist on which network. * diff --git a/apps/dapp/lib/weave/deposit/avalanche.ts b/apps/dapp/lib/weave/deposit/avalanche.ts index a44c7d13..afec5df7 100644 --- a/apps/dapp/lib/weave/deposit/avalanche.ts +++ b/apps/dapp/lib/weave/deposit/avalanche.ts @@ -6,7 +6,6 @@ import { http, isAddress, parseAbi, - parseEventLogs, zeroAddress, type Address, type Hash, @@ -16,6 +15,7 @@ import { } from 'viem' import { avalanche } from 'viem/chains' +import { erc20TransferPaid } from '../../chains/evm/submit' import { ChainActionRefused, toBaseUnits, @@ -87,8 +87,16 @@ function expectedUnits(amount: string): bigint { } } +/** This deposit moves USDC and nothing else, so an order naming another token must not reach it. */ +function assertUsdc(expectation: DepositExpectation): void { + if (expectation.token !== undefined && !same(expectation.token.id, USDC)) { + refuse('Avalanche deposits here move USDC and no other token.') + } +} + function assertSafe(payload: SigningPayload, expectation: DepositExpectation): void { if (payload.family !== 'evm') return refuse('That is not an Avalanche transaction.') + assertUsdc(expectation) if (expectation.memo !== undefined) refuse('Avalanche cannot carry the memo this order needs.') if (!isDepositAddress(expectation.depositAddress)) { refuse('This order’s deposit address cannot take a deposit.') @@ -138,16 +146,12 @@ async function readBalances( } function paidThisOrder(receipt: DepositReceipt, expectation: DepositExpectation): boolean { - if (!same(receipt.from, expectation.account)) return false - if (receipt.to === null || !same(receipt.to, USDC)) return false - const amount = BigInt(toBaseUnits(expectation.amount, USDC_DECIMALS)) - return parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs }).some( - (log) => - same(log.address, USDC) && - same(log.args.from, expectation.account) && - same(log.args.to, expectation.depositAddress) && - log.args.value === amount - ) + return erc20TransferPaid(receipt, { + token: USDC, + account: expectation.account, + depositAddress: expectation.depositAddress, + units: BigInt(toBaseUnits(expectation.amount, USDC_DECIMALS)), + }) } /** Takes the client as a function so nothing connects until a deposit is actually read. */ @@ -197,6 +201,11 @@ export function createAvalancheDeposit(client: () => AvalancheClient): DepositCh 'That is not an Avalanche transaction. Nothing was sent from here.' ) } + if (expectation.token !== undefined && !same(expectation.token.id, USDC)) { + throw new ChainActionRefused( + 'This deposit can only be confirmed as USDC. Nothing was sent from here.' + ) + } const { hash } = signed if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) { throw new ChainActionRefused( diff --git a/apps/dapp/lib/weave/types.ts b/apps/dapp/lib/weave/types.ts index 94d01eb0..630fb36b 100644 --- a/apps/dapp/lib/weave/types.ts +++ b/apps/dapp/lib/weave/types.ts @@ -43,15 +43,18 @@ export interface DepositExpectation { memo: string | undefined /** The token in display units. */ amount: string - /** What to transfer. Absent means USDC, which is all a Weave order ever sends. */ + /** + * What to transfer. Absent means USDC, which is all a Weave order ever sends. Solana and Sui + * move the token named, Avalanche refuses any but USDC, and Stellar and NEAR are never given one. + */ token?: ChainToken | undefined } /** * What each chain supplies for a deposit. `assertSafe` is the check that matters: - * it reads the payload back and admits only one USDC transfer from `account` to the - * order's deposit address for exactly the order's amount, and it throws - * `ChainActionRefused` on anything else. + * it reads the payload back and admits only one transfer of the expectation's token, USDC when + * none is named, from `account` to the order's deposit address for exactly the order's amount, + * and it throws `ChainActionRefused` on anything else. */ export interface DepositChain { chain: CashoutChain diff --git a/apps/dapp/public/images/venues/1click-avalanche.webp b/apps/dapp/public/images/venues/1click-avalanche.webp new file mode 100644 index 0000000000000000000000000000000000000000..bfb03c75d11fe921eb319e403dd916113930f69f GIT binary patch literal 1234 zcmV;@1TFhgNk&G>1ONb6MM6+kP&gpI1ONcg9sr#IDu4ih06sk&jYJ|Lp_EI+gdhXN zprLGpHvrRL_;bmHrTxzRoc|4&2jCx>|2X`Q|0UW}!~^)lz<s|C($eZ!UmJRA6(#kZOcmkO{gPPyRBk!iQ5DtE^z{3ES zIc>ntho_25Q0mNqL52@rB?Ww1IQeR}n5sXZm5b|uc+_?6F}=1fsLWo0h-EqV?ay2K zz4vpj_RgBA*%v6}#OoX$)xMOt@xPJMcl)oI?@itz0ra*9zwxFhrY{9N%wTQH{Ssvw zSYzh)=caTazuWij=_H);TePyP#xZjg;O3hDbg@`unBZf-h&@vB4pVeN?Y>gTWvcE4 zMVk(Bu}dl2RCXJ__V5@#sCK9QNs5!r2KU$0hCt>^v_ouwRr)w2VVH-LozR`)*4h45 zj~!$~4glLYmG3lFke{^kYouUQ-3HZMZ<>SXcVTDw9N zvG6PoMfv4&M{P&9x$^`bK6<(=xtWyimtftBb?o6M5`Ti@!H3OFbiYrHAy9fHXkD&R zYphw&H&~Kv#QUCj0YY*PC;#`K5U*hQNwNBto^9xDtJF2m$P<#p%0{AfQim&WZSTgc zvo0hVYBX{`pb0REWi`CxzwPUsiR~@Tl{ACG1nkl9#57?iEdb<-=}YFGJ5r<<0@~(j z_qiAq?3XORXL2dM&TPFWLtQ5GA^FQ~2<4Vm(2fDg`dkZ&dLB4p@yiLuK5$N{;X_WM z52qiIDAn-C;+2tf!s{%o(a7pIBW$xtt=|+0MePWd<65zi{VK@qK zoi3J-yq{NyAvmFvUV0G#1})m}n%YGFwjCqo02(edxNuLNRISriyP{XXkN5D*!=9ZL z!kG#+S&UaSZHMfTYB4fFG{(?wd+^z2){&%B&HcM0{y(-yiin1qZUV1ONb6MM6+kP&gpI1ONcg9sr#IDu4ih06sk&jYJ|Lp_EI+gdhXN zprLGpHvrRL_;bmHrTxzRoc|4&2jCx>|2X`Q|0UW}!~^)lz<s|C($eZ!UmJRA6(#kZOcmkO{gPPyRBk!iQ5DtE^z{3ES zIc>ntho_25Q0mNqL52@rB?Ww1IQeR}n5sXZm5b|uc+_?6F}=1fsLWo0h-EqV?ay2K zz4vpj_RgBA*%v6}#OoX$)xMOt@xPJMcl)oI?@itz0ra*9zwxFhrY{9N%wTQH{Ssvw zSYzh)=caTazuWij=_H);TePyP#xZjg;O3hDbg@`unBZf-h&@vB4pVeN?Y>gTWvcE4 zMVk(Bu}dl2RCXJ__V5@#sCK9QNs5!r2KU$0hCt>^v_ouwRr)w2VVH-LozR`)*4h45 zj~!$~4glLYmG3lFke{^kYouUQ-3HZMZ<>SXcVTDw9N zvG6PoMfv4&M{P&9x$^`bK6<(=xtWyimtftBb?o6M5`Ti@!H3OFbiYrHAy9fHXkD&R zYphw&H&~Kv#QUCj0YY*PC;#`K5U*hQNwNBto^9xDtJF2m$P<#p%0{AfQim&WZSTgc zvo0hVYBX{`pb0REWi`CxzwPUsiR~@Tl{ACG1nkl9#57?iEdb<-=}YFGJ5r<<0@~(j z_qiAq?3XORXL2dM&TPFWLtQ5GA^FQ~2<4Vm(2fDg`dkZ&dLB4p@yiLuK5$N{;X_WM z52qiIDAn-C;+2tf!s{%o(a7pIBW$xtt=|+0MePWd<65zi{VK@qK zoi3J-yq{NyAvmFvUV0G#1})m}n%YGFwjCqo02(edxNuLNRISriyP{XXkN5D*!=9ZL z!kG#+S&UaSZHMfTYB4fFG{(?wd+^z2){&%B&HcM0{y(-yiin1qZUV Date: Wed, 4 Nov 2026 14:13:21 +0100 Subject: [PATCH 15/24] fix(dapp): bind an evm revert to this transfer and read the chain's name as no venue A revert on a token had only from and to behind it, and to is the contract, so the account's earlier failed call read as nothing moved. The transaction is now read, and only this transfer's exact calldata and value answers clean. Naming the chain you are on counts as no venue named, in the race and at Review, and another chain's name is refused rather than run here. A native MON spend leaving under 10 MON carries a note on the card, and its revert names the reserve as the likely cause. It warns and doesn't refuse, since an undelegated account can succeed. --- apps/dapp/lib/__tests__/evm-module.test.ts | 308 ++++++++++++++++++++- apps/dapp/lib/agents/chain-routes.ts | 5 +- apps/dapp/lib/chains/evm/client.ts | 14 +- apps/dapp/lib/chains/evm/deposit.ts | 54 ++-- apps/dapp/lib/chains/evm/submit.ts | 54 +++- apps/dapp/lib/chains/evm/tokens.ts | 13 + apps/dapp/lib/chains/oneclick-origin.ts | 12 +- apps/dapp/lib/chains/prepare.ts | 37 ++- apps/dapp/lib/chains/signing-flow.ts | 2 +- 9 files changed, 440 insertions(+), 59 deletions(-) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 8335d837..44009eea 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -13,12 +13,14 @@ import { oneClickAssetId, oneClickOriginVenue } from '../chains/oneclick-origin' import { parseChainAction } from '../chains/parse-action' import { assertKnownTokens, prepareChainAction } from '../chains/prepare' import { chainModule } from '../chains/registry' -import { afterSubmitting, quoteExpiredBeforeSigning } from '../chains/signing-flow' +import { afterSubmitting, QUOTE_EXPIRED, quoteExpiredBeforeSigning } from '../chains/signing-flow' import { ChainActionRefused, tokenBySymbol, type ChainActionRequest, + type ChainModule, type ChainToken, + type ChainVenue, type PreparedAction, type SigningPayload, type SubmitResult, @@ -45,7 +47,9 @@ const rpc = vi.hoisted(() => ({ waitForReceipt: (async () => { throw new Error('no receipt') }) as (hash: string) => Promise, - transactionValue: (async () => BigInt(0)) as (hash: string) => Promise, + transaction: (async () => ({ value: BigInt(0), input: '0x' })) as ( + hash: string + ) => Promise<{ value: bigint; input: string }>, reads: [] as string[], })) @@ -60,7 +64,7 @@ vi.mock('../chains/evm/client', async (original) => { rpc.reads.push(hash) return rpc.waitForReceipt(hash) }, - transactionValue: (hash: string) => rpc.transactionValue(hash), + transaction: (hash: string) => rpc.transaction(hash), } return { ...actual, @@ -298,7 +302,7 @@ beforeEach(() => { rpc.waitForReceipt = async () => { throw new Error('no receipt was arranged for this test') } - rpc.transactionValue = async () => { + rpc.transaction = async () => { throw new Error('no transaction was arranged for this test') } rpc.reads = [] @@ -470,6 +474,127 @@ describe('the race on these chains', () => { }) }) +describe('naming the chain you are on', () => { + const moduleFor = (slug: 'avalanche' | 'monad'): ChainModule => { + const found = chainModule(slug) + if (found === undefined) throw new Error('no module') + return found + } + const toNative = { avalanche: 'AVAX', monad: 'MON' } as const + + /** A chain with two venues, so a default and a named venue can be told apart. */ + function twoVenues(chain: 'solana' | 'sui' | 'near'): ChainModule { + const venue = (id: string, name: string): ChainVenue => ({ + id, + name, + kinds: ['swap'], + quote: async () => ({ receive: { amount: '1', symbol: 'B' } }), + prepare: async () => ({ venueId: id }) as PreparedAction, + assertSafe: () => undefined, + }) + return { + chain, + tokens: [ + { symbol: 'A', id: 'a', decimals: 6 }, + { symbol: 'B', id: 'b', decimals: 6 }, + ], + venues: [venue('alpha', 'Alpha Swap'), venue('beta', 'Beta Swap')], + defaultVenue: { swap: 'beta' }, + priceUsd: async () => 1, + submit: async () => ({ ok: false, error: 'unused here' }), + } + } + const ab = (venue: string): ChainActionRequest => ({ + kind: 'swap', + amount: '1', + tokenIn: 'A', + tokenOut: 'B', + venue, + }) + + it('reads as it was typed', () => { + expect(parseChainAction('swap 10 USDC to MON on monad')).toMatchObject({ venue: 'monad' }) + }) + + it.each<['avalanche' | 'monad', string]>([ + ['avalanche', 'avalanche'], + ['avalanche', 'Avalanche'], + ['avalanche', ' AVALANCHE '], + ['monad', 'monad'], + ['monad', 'Monad'], + ])('races on %s when told "on %s"', async (slug, venue) => { + const request = swap({ tokenOut: toNative[slug], venue }) + const routes = await quoteChainRoutes(moduleFor(slug), request, ACCOUNT) + expect(routes.map((r) => r.id)).toEqual([`1click-${slug}-1`]) + }) + + it.each<['avalanche' | 'monad', string]>([ + ['avalanche', 'avalanche'], + ['monad', 'Monad'], + ])('builds on %s when told "on %s"', async (slug, venue) => { + const request = swap({ tokenOut: toNative[slug], venue }) + const prepared = await prepareChainAction(moduleFor(slug), request, ACCOUNT, {}) + expect(prepared.venueId).toBe(`1click-${slug}`) + }) + + it.each(['solana', 'Solana', 'sui', 'near', 'NEAR', 'stellar', 'avalanche', 'arc'])( + 'does not quietly run on Monad when told "on %s"', + async (venue) => { + const request = swap({ tokenOut: 'MON', venue }) + await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([]) + await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow( + /can't swap here/ + ) + expect(sim.quotes.filter((q) => q['dry'] === false)).toEqual([]) + } + ) + + it.each(['1click', 'NEAR Intents', '1click-monad'])( + 'still takes a real venue name on Monad: %s', + async (venue) => { + const request = swap({ tokenOut: 'MON', venue }) + const routes = await quoteChainRoutes(moduleFor('monad'), request, ACCOUNT) + expect(routes.map((r) => r.id)).toEqual(['1click-monad-1']) + const prepared = await prepareChainAction(moduleFor('monad'), request, ACCOUNT, {}) + expect(prepared.venueId).toBe('1click-monad') + } + ) + + it('refuses a venue that isn’t here', async () => { + const request = swap({ tokenOut: 'MON', venue: 'jupiter' }) + await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([]) + await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow( + /"jupiter" can't swap here/ + ) + }) + + it.each<['solana' | 'sui' | 'near', string]>([ + ['solana', 'solana'], + ['sui', 'Sui'], + ['near', 'NEAR'], + ])('counts as no venue named on %s too: "%s"', async (chain, venue) => { + const api = twoVenues(chain) + const routes = await quoteChainRoutes(api, ab(venue), ACCOUNT) + expect(routes.map((r) => r.id)).toEqual(['alpha-1', 'beta-1']) + await expect(prepareChainAction(api, ab(venue), ACCOUNT, {})).resolves.toMatchObject({ + venueId: 'beta', + }) + }) + + it('still filters by a real venue, and refuses another chain, on those chains', async () => { + const api = twoVenues('solana') + const routes = await quoteChainRoutes(api, ab('alpha'), ACCOUNT) + expect(routes.map((r) => r.id)).toEqual(['alpha-1']) + await expect(prepareChainAction(api, ab('alpha'), ACCOUNT, {})).resolves.toMatchObject({ + venueId: 'alpha', + }) + await expect(quoteChainRoutes(api, ab('monad'), ACCOUNT)).resolves.toEqual([]) + await expect(prepareChainAction(api, ab('monad'), ACCOUNT, {})).rejects.toThrow( + /"monad" can't swap here/ + ) + }) +}) + describe('the Apps page rows', () => { const on = (slug: 'avalanche' | 'monad' | 'arc' | 'stellar'): string[] => venues.filter((v) => venueOnChain(v, CHAIN_DESCRIPTORS[slug])).map((v) => v.id) @@ -795,6 +920,41 @@ describe('prepare', () => { expectNoAddressAsked() }) + describe('Monad’s 10 MON reserve', () => { + const TEN_MON = BigInt(10) * BigInt(10) ** BigInt(18) + const HUNDRED_MON = BigInt(100) * BigInt(10) ** BigInt(18) + const monSwap = swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' }) + const hasNote = (prepared: PreparedAction): boolean => + prepared.notes.some((note) => /10 MON reserve/.test(note)) + + it('says so on the card, and still builds, when a spend leaves under 10 MON', async () => { + rpc.nativeBalance = async () => HUNDRED_MON + TEN_MON - BigInt(1) + const prepared = await venueMonad.prepare(monSwap, ACCOUNT) + expect(hasNote(prepared)).toBe(true) + expect(prepared.notes.join(' ')).toMatch(/gas/) + expect(prepared.signing).toMatchObject({ family: 'evm', chainId: 143 }) + }) + + it('says nothing when a spend leaves exactly 10 MON, or more', async () => { + rpc.nativeBalance = async () => HUNDRED_MON + TEN_MON + expect(hasNote(await venueMonad.prepare(monSwap, ACCOUNT))).toBe(false) + rpc.nativeBalance = async () => HUNDRED_MON * BigInt(2) + expect(hasNote(await venueMonad.prepare(monSwap, ACCOUNT))).toBe(false) + }) + + it('says nothing for a token, which leaves the coin alone', async () => { + rpc.nativeBalance = async () => BigInt(EVM_CHAINS.monad.gasReserve) + expect(hasNote(await venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT))).toBe(false) + }) + + it('says nothing on Avalanche, which has no such rule', async () => { + rpc.nativeBalance = async () => + BigInt(ONE_AND_A_HALF_AVAX) + BigInt(EVM_CHAINS.avalanche.gasReserve) + const prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) + expect(prepared.notes.join(' ')).not.toMatch(/reserve/i) + }) + }) + it('does not read balances for a race quote', async () => { rpc.tokenBalance = async () => { throw new Error('a quote must not read this') @@ -848,7 +1008,7 @@ describe('submit: what the chain says the wallet sent', () => { beforeEach(async () => { prepared = await venueAvalanche.prepare(avaxSwap(), ACCOUNT) - rpc.transactionValue = async () => BigInt(ONE_AND_A_HALF_AVAX) + rpc.transaction = async () => ({ value: BigInt(ONE_AND_A_HALF_AVAX), input: '0x' }) }) it('accepts the transfer, tells 1Click, and is done when 1Click says so', async () => { @@ -875,7 +1035,10 @@ describe('submit: what the chain says the wallet sent', () => { it('reads the value, since a receipt has none, and refuses any other value', async () => { rpc.waitForReceipt = async () => receipt() - rpc.transactionValue = async () => BigInt(ONE_AND_A_HALF_AVAX) - BigInt(1) + rpc.transaction = async () => ({ + value: BigInt(ONE_AND_A_HALF_AVAX) - BigInt(1), + input: '0x', + }) const result = await submitTo(prepared) isPending(result) expect(sim.notified).toEqual([]) @@ -883,7 +1046,7 @@ describe('submit: what the chain says the wallet sent', () => { it('stays pending when a landed transfer’s value cannot be read', async () => { rpc.waitForReceipt = async () => receipt() - rpc.transactionValue = async () => { + rpc.transaction = async () => { throw new Error('rpc down') } isPending(await submitTo(prepared)) @@ -902,15 +1065,118 @@ describe('submit: what the chain says the wallet sent', () => { isPending(await submitTo(prepared)) } }) + + it('does not call a revert clean when the transaction carried data, or can’t be read', async () => { + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt(ONE_AND_A_HALF_AVAX), input: '0xdeadbeef' }) + isPending(await submitTo(prepared)) + rpc.transaction = async () => ({ value: BigInt(1), input: '0x' }) + isPending(await submitTo(prepared)) + rpc.transaction = async () => { + throw new Error('rpc down') + } + isPending(await submitTo(prepared)) + }) + + it('names no reserve on Avalanche, which has none', async () => { + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + expect(await submitTo(prepared)).not.toMatchObject({ + error: expect.stringMatching(/reserve/i), + }) + }) + }) + + describe('a native transfer on Monad that reverts', () => { + it('names the 10 MON reserve as the likely cause', async () => { + const prepared = await venueMonad.prepare( + swap({ tokenIn: 'MON', tokenOut: 'USDC', amount: '100' }), + ACCOUNT + ) + rpc.waitForReceipt = async () => receipt({ status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt('100000000000000000000'), input: '0x' }) + const clean = await submitTo(prepared, venueMonad) + withoutPending(clean) + expect(clean).toMatchObject({ error: expect.stringMatching(/10 MON reserve/) }) + expect(clean).toMatchObject({ error: expect.stringMatching(/MON did not move/) }) + }) + + it('does not blame the reserve for a token transfer that reverts on Monad', async () => { + const prepared = await venueMonad.prepare(swap({ tokenOut: 'MON' }), ACCOUNT) + rpc.waitForReceipt = async () => receipt({ status: 'reverted', to: MONAD_USDC.id }) + rpc.transaction = async () => ({ + value: BigInt(0), + input: encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS], + }), + }) + const clean = await submitTo(prepared, venueMonad) + withoutPending(clean) + expect(clean).not.toMatchObject({ error: expect.stringMatching(/reserve/i) }) + }) }) describe('an ERC-20 transfer', () => { let prepared: PreparedAction const paying = (logs: unknown[], change: Json = {}): Json => receipt({ to: AVAX_USDC.id, logs, ...change }) + const thisTransfer = encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS], + }) beforeEach(async () => { prepared = await venueAvalanche.prepare(swap(), ACCOUNT) + rpc.transaction = async () => ({ value: BigInt(0), input: thisTransfer }) + }) + + it.each<[string, string]>([ + [ + 'a transfer to someone else', + encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [OTHER, USD_UNITS], + }), + ], + [ + 'a transfer of another amount', + encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [DEPOSIT, USD_UNITS + BigInt(1)], + }), + ], + ['an approve', `0x095ea7b3${addressWord(DEPOSIT)}${word('17d7840')}`], + ['this transfer with bytes after it', `${thisTransfer}00`], + ['nothing', '0x'], + ])( + 'does not read an earlier failed call on this token, %s, as this transfer failing', + async (_name, input) => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt(0), input }) + const result = await submitTo(prepared) + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/did not move/) }) + } + ) + + it('does not call a revert this transfer when it also carried value', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + rpc.transaction = async () => ({ value: BigInt(1), input: thisTransfer }) + isPending(await submitTo(prepared)) + }) + + it('stays pending on a revert when the transaction can’t be read', async () => { + rpc.waitForReceipt = async () => paying([], { status: 'reverted' }) + rpc.transaction = async () => { + throw new Error('rpc down') + } + const result = await submitTo(prepared) + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/did not move/) }) }) it('accepts a Transfer log of exactly this amount, from the account to the deposit', async () => { @@ -1193,6 +1459,23 @@ describe('submit: what the chain says the wallet sent', () => { }) }) + it('reads the input of a real reverted transaction before calling it clean', async () => { + const reverted = { ...rpcReceipt, status: '0x0' } + const clean = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: reverted, + eth_getTransactionByHash: rpcTransaction(ONE_HUNDRED_MON_HEX), + }) + withoutPending(await clean.submit({ family: 'evm', hash: HASH }, order)) + + const other = await deposit({ + eth_blockNumber: '0x2', + eth_getTransactionReceipt: reverted, + eth_getTransactionByHash: { ...rpcTransaction(ONE_HUNDRED_MON_HEX), input: '0xdeadbeef' }, + }) + isPending(await other.submit({ family: 'evm', hash: HASH }, order)) + }) + it('refuses a real transaction of another value', async () => { const chain = await deposit({ eth_blockNumber: '0x2', @@ -1304,8 +1587,10 @@ describe('the submit route', () => { ])('accepts { family: evm, hash } on %s and follows it', async (chain, venue, request) => { const prepared = await venue.prepare(request(), ACCOUNT) rpc.waitForReceipt = async () => receipt() - rpc.transactionValue = async () => - BigInt((prepared.signing as Extract).value) + rpc.transaction = async () => ({ + value: BigInt((prepared.signing as Extract).value), + input: '0x', + }) const res = await post(chain, { account: ACCOUNT, venueId: venue.id, @@ -1374,6 +1659,11 @@ describe('a stale card, before the wallet opens', () => { expect(quoteExpiredBeforeSigning(card('evm'), NOW)).toBe(false) }) + it('says what to do in words true on both cards, since the direct one has no Review', () => { + expect(QUOTE_EXPIRED).toMatch(/nothing was sent/) + expect(QUOTE_EXPIRED).not.toMatch(/Review/) + }) + it('is left to the server on chains whose bytes it sends, and to NEAR as it was', () => { for (const family of ['solana', 'sui', 'near'] as const) { expect(quoteExpiredBeforeSigning(card(family, '2020-01-01T00:00:00.000Z'), NOW)).toBe(false) diff --git a/apps/dapp/lib/agents/chain-routes.ts b/apps/dapp/lib/agents/chain-routes.ts index 2bc8913c..7c2153d7 100644 --- a/apps/dapp/lib/agents/chain-routes.ts +++ b/apps/dapp/lib/agents/chain-routes.ts @@ -1,3 +1,4 @@ +import { namedVenue, venueAnswersTo } from '../chains/prepare' import { tokenBySymbol, type ChainActionRequest, type ChainModule } from '../chains/types' import type { ParsedIntent } from '../parse-intent' import type { QuotedRoute } from './brain' @@ -28,12 +29,12 @@ export async function quoteChainRoutes( request: ChainActionRequest, account: string ): Promise { - const named = request.venue?.trim().toLowerCase() + const named = namedVenue(chainApi, request) const usable = chainApi.venues.filter( (v) => v.kinds.includes(request.kind) && v.quote !== undefined && - (named === undefined || v.id.toLowerCase() === named || v.name.toLowerCase().includes(named)) + (named === undefined || venueAnswersTo(v, named)) ) // Dollars per display unit, from the chain's own price source. Display diff --git a/apps/dapp/lib/chains/evm/client.ts b/apps/dapp/lib/chains/evm/client.ts index 55ecd097..20926501 100644 --- a/apps/dapp/lib/chains/evm/client.ts +++ b/apps/dapp/lib/chains/evm/client.ts @@ -16,12 +16,17 @@ const RECEIPT_TIMEOUT_MS = 20_000 export type EvmReceipt = Pick +export interface EvmTransaction { + value: bigint + input: string +} + export interface EvmClient { tokenBalance: (token: Address, account: Address) => Promise nativeBalance: (account: Address) => Promise waitForReceipt: (hash: Hash) => Promise - /** A receipt doesn't carry what a transaction paid, so this is read apart from it. */ - transactionValue: (hash: Hash) => Promise + /** A receipt doesn't carry what a transaction paid or called, so these are read apart from it. */ + transaction: (hash: Hash) => Promise } /** Reads this chain only, so a hash from another chain isn't found here. */ @@ -44,6 +49,9 @@ export function evmClient(chain: EvmChain, transport?: Transport): EvmClient { nativeBalance: (address) => client.getBalance({ address }), waitForReceipt: (hash) => client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }), - transactionValue: async (hash) => (await client.getTransaction({ hash })).value, + transaction: async (hash) => { + const { value, input } = await client.getTransaction({ hash }) + return { value, input } + }, } } diff --git a/apps/dapp/lib/chains/evm/deposit.ts b/apps/dapp/lib/chains/evm/deposit.ts index 93d2a43d..63faac22 100644 --- a/apps/dapp/lib/chains/evm/deposit.ts +++ b/apps/dapp/lib/chains/evm/deposit.ts @@ -1,12 +1,4 @@ -import { - decodeFunctionData, - encodeFunctionData, - isAddress, - parseAbi, - zeroAddress, - type Address, - type Hex, -} from 'viem' +import { decodeFunctionData, isAddress, zeroAddress, type Address, type Hex } from 'viem' import type { DepositChain, DepositExpectation } from '../../weave/types' import { formatUnits } from '../near/tokens' @@ -18,17 +10,24 @@ import { type SigningPayload, } from '../types' import { evmClient, type EvmClient } from './client' -import { confirmEvmTransfer, sameAddress } from './submit' +import { + confirmEvmTransfer, + sameAddress, + sameCalldata, + TRANSFER_ABI, + transferCalldata, +} from './submit' import { EVM_CHAINS, EVM_NATIVE_ID, type EvmChain } from './tokens' -const TRANSFER_ABI = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']) - export interface EvmDeposit extends Pick< DepositChain, 'isAccount' | 'isDepositAddress' | 'build' | 'assertSafe' | 'submit' > { - /** Refuses a swap this account can't pay for, with its gas. Fails closed if it can't be read. */ - assertFunds: (account: string, token: ChainToken, amount: string) => Promise + /** + * Refuses a swap this account can't pay for, with its gas. Fails closed if it can't be read. + * Answers with any notes the card should carry about what it read. + */ + assertFunds: (account: string, token: ChainToken, amount: string) => Promise } function refuse(reason: string): never { @@ -49,7 +48,14 @@ function unitsOf(amount: string, token: ChainToken): bigint { * naming USDT must not be able to build or pass as USDC. */ export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmDeposit { - const { name, chain: viemChain, tokens, nativeSymbol, gasReserve } = EVM_CHAINS[chain] + const { + name, + chain: viemChain, + tokens, + nativeSymbol, + gasReserve, + spendReserve, + } = EVM_CHAINS[chain] function isAccount(address: string): boolean { return isAddress(address, { strict: false }) && !sameAddress(address, zeroAddress) @@ -109,12 +115,7 @@ export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmD } if (sent !== units) refuse('That transfer’s amount is not this order’s amount.') // The decoder ignores what comes after the arguments and above an address's 20 bytes. - const canonical = encodeFunctionData({ - abi: TRANSFER_ABI, - functionName: 'transfer', - args: [recipient, sent], - }) - if (!sameAddress(canonical, payload.data)) { + if (!sameCalldata(transferCalldata(recipient, sent), payload.data)) { refuse('That transaction carries extra data, and must be exactly one transfer.') } } @@ -146,7 +147,9 @@ export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmD `This account needs ${amount} ${nativeSymbol} for the swap and about ${kept} more for the network fee, and holds less than that.` ) } - return + return spendReserve !== undefined && coin - units < BigInt(spendReserve.units) + ? [spendReserve.note] + : [] } if (held === undefined || held < units) { refuse(`This account holds less ${token.symbol} than this swap needs.`) @@ -156,6 +159,7 @@ export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmD `This account has too little ${nativeSymbol} for the network fee. Keep about ${kept}.` ) } + return [] }, build: async (expectation) => { @@ -175,11 +179,7 @@ export function createEvmDeposit(chain: EvmChain, client: () => EvmClient): EvmD family: 'evm', chainId: viemChain.id, to: token.id, - data: encodeFunctionData({ - abi: TRANSFER_ABI, - functionName: 'transfer', - args: [expectation.depositAddress as Address, units], - }), + data: transferCalldata(expectation.depositAddress, units), value: '0', } assertSafe(payload, expectation) diff --git a/apps/dapp/lib/chains/evm/submit.ts b/apps/dapp/lib/chains/evm/submit.ts index ff236a74..3e3ba2d2 100644 --- a/apps/dapp/lib/chains/evm/submit.ts +++ b/apps/dapp/lib/chains/evm/submit.ts @@ -1,16 +1,39 @@ -import { erc20Abi, parseEventLogs, type Hash } from 'viem' +import { + encodeFunctionData, + erc20Abi, + parseAbi, + parseEventLogs, + type Address, + type Hash, +} from 'viem' import type { DepositExpectation } from '../../weave/types' import { toBaseUnits, type SignedPayload, type SubmitResult } from '../types' -import type { EvmClient, EvmReceipt } from './client' +import type { EvmClient, EvmReceipt, EvmTransaction } from './client' import { EVM_CHAINS, EVM_NATIVE_ID, evmExplorerUrl, type EvmChain } from './tokens' export const EVM_HASH = /^0x[0-9a-fA-F]{64}$/ +export const TRANSFER_ABI = parseAbi([ + 'function transfer(address to, uint256 amount) returns (bool)', +]) + export function sameAddress(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase() } +export function sameCalldata(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +export function transferCalldata(to: string, units: bigint): string { + return encodeFunctionData({ + abi: TRANSFER_ABI, + functionName: 'transfer', + args: [to as Address, units], + }) +} + /** Whether this receipt shows this account paying exactly this token transfer to this address. */ export function erc20TransferPaid( receipt: EvmReceipt, @@ -34,8 +57,9 @@ function pending(error: string): SubmitResult { /** * Asks the chain what the wallet sent. The wallet broadcast this for itself, so nothing here can * stop it, and an answer may lack `pending` only if it proves the funds did not move. Only a - * revert does, and only from this account to the address this transfer was meant for: a browser - * could hand in any failed transaction while the real transfer went out. + * revert does, and only of this very transfer: from this account, to that address, carrying + * exactly this calldata and value. For a token `to` is the contract, so from and to alone would + * pass the account's earlier failed call on that token while the real transfer went out. * * A receipt that is someone else's or for something else says nothing about the real transfer, * so it is pending too, and it is never reported to 1Click. @@ -46,7 +70,7 @@ export async function confirmEvmTransfer( signed: SignedPayload, expectation: DepositExpectation ): Promise { - const { name } = EVM_CHAINS[chain] + const { name, spendReserve } = EVM_CHAINS[chain] const { account, depositAddress, amount, token } = expectation if (signed.family !== 'evm' || !EVM_HASH.test(signed.hash) || token === undefined) { return pending( @@ -75,18 +99,32 @@ export async function confirmEvmTransfer( sameAddress(receipt.from, account) && receipt.to !== null && sameAddress(receipt.to, expectedTo) if (!fromThisAccountToThatAddress) return notThisSwap + const units = BigInt(toBaseUnits(amount, token.decimals)) + if (receipt.status === 'reverted') { + let sent: EvmTransaction + try { + sent = await client().transaction(hash as Hash) + } catch { + return pending( + `Transaction ${hash} failed on ${name}, but it couldn't be read to tell whether it was this swap's transfer. Check your wallet's activity before trying again, in case the real one went out.` + ) + } + const isThisTransfer = + sent.value === (native ? units : BigInt(0)) && + sameCalldata(sent.input, native ? '0x' : transferCalldata(depositAddress, units)) + if (!isThisTransfer) return notThisSwap + const likelyCause = native && spendReserve !== undefined ? ` ${spendReserve.reverted}` : '' return { ok: false, - error: `This transfer failed on ${name}. The ${token.symbol} did not move, and only the gas was spent.`, + error: `This transfer failed on ${name}. The ${token.symbol} did not move, and only the gas was spent.${likelyCause}`, } } - const units = BigInt(toBaseUnits(amount, token.decimals)) if (native) { let value: bigint try { - value = await client().transactionValue(hash as Hash) + ;({ value } = await client().transaction(hash as Hash)) } catch { return notConfirmed } diff --git a/apps/dapp/lib/chains/evm/tokens.ts b/apps/dapp/lib/chains/evm/tokens.ts index fc766b58..abc24be5 100644 --- a/apps/dapp/lib/chains/evm/tokens.ts +++ b/apps/dapp/lib/chains/evm/tokens.ts @@ -59,6 +59,11 @@ export interface EvmChainConfig { * because the wallet sets the real fee. */ gasReserve: string + /** + * A balance the chain itself protects, where it has one. A spend that dips under it can revert + * after it is signed, but not always, so this warns and doesn't refuse. + */ + spendReserve?: { units: string; note: string; reverted: string } rpcEnv: string explorer: string } @@ -79,6 +84,14 @@ export const EVM_CHAINS: Record = { tokens: MONAD_TOKENS, nativeSymbol: 'MON', gasReserve: '50000000000000000', + // https://docs.monad.xyz/developer-essentials/reserve-balance. It reverts always for an + // EIP-7702-delegated account, and for others that sent within the last 3 blocks. + spendReserve: { + units: '10000000000000000000', + note: 'This would leave under 10 MON in your wallet. Monad holds a 10 MON reserve on each account, so this swap may fail because of it, and the gas would still be spent.', + reverted: + 'The likely cause is Monad’s 10 MON reserve, which can stop a spend that leaves an account under 10 MON.', + }, rpcEnv: 'MONAD_RPC_URL', explorer: 'https://monadvision.com', }, diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts index c1e62cda..23295f94 100644 --- a/apps/dapp/lib/chains/oneclick-origin.ts +++ b/apps/dapp/lib/chains/oneclick-origin.ts @@ -106,8 +106,11 @@ interface Origin { /** The chain's own swap, which takes what 1Click doesn't here. Absent where 1Click is the only one. */ ownSwap?: string deposit: Pick - /** Reads what the wallet holds before a deposit address is asked for, where the build won't. */ - assertFunds?: (account: string, token: ChainToken, amount: string) => Promise + /** + * Reads what the wallet holds before a deposit address is asked for, where the build won't. + * Answers with any notes the card should carry about what it read. + */ + assertFunds?: (account: string, token: ChainToken, amount: string) => Promise /** The chain's identifier for signed bytes, known before the network has answered. */ hashOf: (signed: SignedPayload) => string | undefined explorerUrl: (hash: string) => string @@ -429,7 +432,7 @@ async function send( } } -/** The venue behind a 1Click swap from `chain`. Refuses plainly on a chain with no deposit built. */ +/** The venue behind a 1Click swap from `chain`. */ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { const venueId = `1click-${chain}` @@ -466,7 +469,7 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { } = await routeFor(origin, chain, request, account) const key = signingKey() // Before the real quote, so someone who can't pay never gets a deposit address made. - await origin.assertFunds?.(account, tokenIn, request.amount) + const fundsNotes = (await origin.assertFunds?.(account, tokenIn, request.amount)) ?? [] const { shared } = await previewOneClick(request.amount, spending, destination, account) const { live, loss, deadline } = await liveOneClick(shared, spending, destination) @@ -501,6 +504,7 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue { notes: [ costNote(live, loss, destination.symbol, destination.decimals, COST_WORDING), ...(origin.walletCost !== undefined ? [origin.walletCost.note] : []), + ...fundsNotes, depositNote(live, tokenIn, account), ONECLICK_RISK_NOTE, ], diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index 032bf56d..f0a3a085 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -1,3 +1,5 @@ +import { CHAIN_DESCRIPTORS } from '@intent/config' + import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade-cap' import { ChainActionRefused, @@ -9,13 +11,38 @@ import { type PreparedAction, } from './types' +const CHAIN_NAMES = new Set( + Object.values(CHAIN_DESCRIPTORS).flatMap((chain) => [chain.slug, chain.name.toLowerCase()]) +) + +/** + * The venue a request names, in lower case. "swap 10 USDC to MON on monad" reads "monad" as one, + * but it only says where the person already is, so the chain they are on counts as none named. + */ +export function namedVenue( + chainApi: Pick, + request: Pick +): string | undefined { + const wanted = request.venue?.trim().toLowerCase() + if (wanted === undefined) return undefined + const { slug, name } = CHAIN_DESCRIPTORS[chainApi.chain] + return wanted === slug || wanted === name.toLowerCase() ? undefined : wanted +} + +/** + * Whether a venue answers to that name. Another chain's name answers to nothing, so nothing runs + * here that was asked for there, even where it is part of a venue's name, as NEAR is of 1Click's. + */ +export function venueAnswersTo(venue: Pick, wanted: string): boolean { + if (CHAIN_NAMES.has(wanted)) return false + return venue.id.toLowerCase() === wanted || venue.name.toLowerCase().includes(wanted) +} + function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVenue { const usable = chainApi.venues.filter((v) => v.kinds.includes(request.kind)) - if (request.venue !== undefined) { - const wanted = request.venue.trim().toLowerCase() - const named = usable.find( - (v) => v.id.toLowerCase() === wanted || v.name.toLowerCase().includes(wanted) - ) + const wanted = namedVenue(chainApi, request) + if (wanted !== undefined) { + const named = usable.find((v) => venueAnswersTo(v, wanted)) if (named === undefined) { const names = usable.map((v) => v.name).join(', ') throw new ChainActionRefused( diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index b52a1e6a..a00e80c6 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -21,7 +21,7 @@ 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. Press Review again for a fresh one.' + 'This quote expired before you signed, so your wallet was not asked and nothing was sent. Send your request again for a fresh one.' /** * Whether a card has outlived its quote, on a chain where this is the last chance to say so. The From 1901c7bbc58e4feb82362d7479c872c59c4a0e7b Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:16:59 +0100 Subject: [PATCH 16/24] fix(dapp): answer pending when an evm revert or amount can't be read A read that resolved to a transaction with no input, or to null, threw past the pending answer, because only the read was inside the try. A malformed amount threw before any answer too. Both are pending now, and never the clean sentence. --- apps/dapp/lib/__tests__/evm-module.test.ts | 64 ++++++++++++++++++++++ apps/dapp/lib/chains/evm/submit.ts | 24 +++++--- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 44009eea..7c4b0048 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { singleRouteNote } from '../agents/chain-race' import { quoteChainRoutes } from '../agents/chain-routes' import { chainCapabilities } from '../chains/capabilities' +import type { EvmClient, EvmReceipt } from '../chains/evm/client' import { createEvmDeposit } from '../chains/evm/deposit' import { buildEvmVenues } from '../chains/evm/module' import { AVALANCHE_TOKENS, EVM_CHAINS, EVM_NATIVE_ID, MONAD_TOKENS } from '../chains/evm/tokens' @@ -1386,6 +1387,69 @@ describe('submit: what the chain says the wallet sent', () => { }) }) + // At the deposit itself, because the venue would turn a throw into pending and hide it here. + describe('an answer that makes no sense still gets an answer', () => { + const usdcOrder = { + account: ACCOUNT, + depositAddress: DEPOSIT, + memo: undefined, + amount: '25', + token: AVAX_USDC, + } + const avaxOrder = { ...usdcOrder, amount: '1.5', token: AVAX } + + const deposit = (receiptTo: string, status: string, transaction: () => Promise) => + createEvmDeposit('avalanche', () => ({ + tokenBalance: async () => BigInt(0), + nativeBalance: async () => BigInt(0), + waitForReceipt: async () => receipt({ status, to: receiptTo }) as unknown as EvmReceipt, + transaction: transaction as EvmClient['transaction'], + })) + + function pendingAndClaimsNothing(result: SubmitResult): void { + isPending(result) + expect(result).not.toMatchObject({ error: expect.stringMatching(/did not move/) }) + } + + it.each<[string, () => Promise]>([ + ['no input', async () => ({ value: BigInt(0) })], + ['null', async () => null], + ['an input that is not text', async () => ({ value: BigInt(0), input: 7 })], + ])('a token revert whose read resolves to %s is pending', async (_name, read) => { + const chain = deposit(AVAX_USDC.id, 'reverted', read) + pendingAndClaimsNothing(await chain.submit({ family: 'evm', hash: HASH }, usdcOrder)) + }) + + it.each<[string, () => Promise]>([ + ['no input', async () => ({ value: BigInt(ONE_AND_A_HALF_AVAX) })], + ['null', async () => null], + ])('a native revert whose read resolves to %s is pending', async (_name, read) => { + const chain = deposit(DEPOSIT, 'reverted', read) + pendingAndClaimsNothing(await chain.submit({ family: 'evm', hash: HASH }, avaxOrder)) + }) + + it.each(['not a number', '', '0', '1.1234567'])( + 'a malformed amount, %j, is pending on a revert and on a landed transfer', + async (amount) => { + for (const status of ['reverted', 'success']) { + const read = async () => ({ value: BigInt(0), input: '0x' }) + const chain = deposit(AVAX_USDC.id, status, read) + pendingAndClaimsNothing( + await chain.submit({ family: 'evm', hash: HASH }, { ...usdcOrder, amount }) + ) + } + } + ) + + it('a malformed native amount is pending on a revert too', async () => { + const read = async () => ({ value: BigInt(0), input: '0x' }) + const chain = deposit(DEPOSIT, 'reverted', read) + pendingAndClaimsNothing( + await chain.submit({ family: 'evm', hash: HASH }, { ...avaxOrder, amount: 'not a number' }) + ) + }) + }) + describe('through the real client', () => { function transport(answers: Record) { return custom({ diff --git a/apps/dapp/lib/chains/evm/submit.ts b/apps/dapp/lib/chains/evm/submit.ts index 3e3ba2d2..bcf7e02d 100644 --- a/apps/dapp/lib/chains/evm/submit.ts +++ b/apps/dapp/lib/chains/evm/submit.ts @@ -9,7 +9,7 @@ import { import type { DepositExpectation } from '../../weave/types' import { toBaseUnits, type SignedPayload, type SubmitResult } from '../types' -import type { EvmClient, EvmReceipt, EvmTransaction } from './client' +import type { EvmClient, EvmReceipt } from './client' import { EVM_CHAINS, EVM_NATIVE_ID, evmExplorerUrl, type EvmChain } from './tokens' export const EVM_HASH = /^0x[0-9a-fA-F]{64}$/ @@ -99,20 +99,30 @@ export async function confirmEvmTransfer( sameAddress(receipt.from, account) && receipt.to !== null && sameAddress(receipt.to, expectedTo) if (!fromThisAccountToThatAddress) return notThisSwap - const units = BigInt(toBaseUnits(amount, token.decimals)) + // With no amount to compare against, this can't be shown to be this transfer, or not to be. + let units: bigint + try { + units = BigInt(toBaseUnits(amount, token.decimals)) + } catch { + return pending( + `Transaction ${hash} couldn't be checked against this swap's amount. Check your wallet's activity before trying again, in case it went out.` + ) + } if (receipt.status === 'reverted') { - let sent: EvmTransaction + // The comparison sits in here too: an RPC can answer with a shape that makes it throw, and + // that says no more about the transfer than a read that failed. + let isThisTransfer: boolean try { - sent = await client().transaction(hash as Hash) + const sent = await client().transaction(hash as Hash) + isThisTransfer = + sent.value === (native ? units : BigInt(0)) && + sameCalldata(sent.input, native ? '0x' : transferCalldata(depositAddress, units)) } catch { return pending( `Transaction ${hash} failed on ${name}, but it couldn't be read to tell whether it was this swap's transfer. Check your wallet's activity before trying again, in case the real one went out.` ) } - const isThisTransfer = - sent.value === (native ? units : BigInt(0)) && - sameCalldata(sent.input, native ? '0x' : transferCalldata(depositAddress, units)) if (!isThisTransfer) return notThisSwap const likelyCause = native && spendReserve !== undefined ? ` ${spendReserve.reverted}` : '' return { From b12fe605f02615a7485dec3a1aaf581b138cde4f Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:27:32 +0100 Subject: [PATCH 17/24] feat(dapp): record the chain of every agent race Races carry the chain they ran on, other chains' races and submits are logged as mainnet, a submit only links a race of its own chain, and the history route reads every chain's account. --- apps/dapp/app/api/agents/compete/route.ts | 4 +- apps/dapp/app/api/agents/history/route.ts | 15 +- .../__tests__/agents-chain-compete.test.ts | 183 +++++++++++++++++- apps/dapp/lib/__tests__/fakes/analytics-db.ts | 24 ++- apps/dapp/lib/server/analytics.ts | 20 +- .../db/migrations/014_agent_races_chain.sql | 5 + 6 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 packages/db/migrations/014_agent_races_chain.sql diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index c1e73877..2420b0ee 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -441,7 +441,9 @@ export async function POST(request: Request): Promise { ): Promise => logRace({ id: competitionId, - network, + // Other chains only run on mainnet, and their submits are logged there too. + network: chain === 'stellar' ? network : 'mainnet', + chain, startedAt, durationMs: Date.now() - startedAt.getTime(), intent: { diff --git a/apps/dapp/app/api/agents/history/route.ts b/apps/dapp/app/api/agents/history/route.ts index e4aaf623..532d825a 100644 --- a/apps/dapp/app/api/agents/history/route.ts +++ b/apps/dapp/app/api/agents/history/route.ts @@ -2,6 +2,7 @@ import { activeNetwork } from '@intent/config' import { StrKey } from '@stellar/stellar-sdk' import { NextResponse } from 'next/server' +import { isWalletAddress } from '../../../../lib/wallet/address' import { databaseConfigured } from '../../../../lib/server/db' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { getAnalyticsRepo } from '../../../../lib/server/analytics' @@ -13,6 +14,15 @@ export const dynamic = 'force-dynamic' const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 +const STELLAR_SHAPE = /^G[A-Z2-7]{55}$/ + +/** A Stellar address is checked by its checksum; the other chains' by the shape the app accepts. */ +function validAccount(account: string): boolean { + return STELLAR_SHAPE.test(account) + ? StrKey.isValidEd25519PublicKey(account) + : isWalletAddress(account) +} + /** * A wallet's own agent races, each with the winning agent's reasoning and * the transaction this wallet actually submitted for it, when there was one. @@ -34,7 +44,7 @@ export async function GET(request: Request): Promise { const url = new URL(request.url) const account = url.searchParams.get('account') - if (account === null || !StrKey.isValidEd25519PublicKey(account)) { + if (account === null || !validAccount(account)) { return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) } @@ -49,7 +59,8 @@ export async function GET(request: Request): Promise { try { const repo = await getAnalyticsRepo() const result = await repo.historyForAccount({ - network: activeNetwork(), + // Other chains' actions are logged as mainnet, whatever Stellar network the page is on. + network: STELLAR_SHAPE.test(account) ? activeNetwork() : 'mainnet', account, page, pageSize, diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index b50c73e1..10bdea9b 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -1,5 +1,8 @@ +import { StrKey } from '@stellar/stellar-sdk' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fakeAnalyticsDb, type FakeAnalyticsDb } from './fakes/analytics-db' + /** * The race on a chain other than Stellar. The chain module, the roster and the * market are faked; under test is that a chain action starts a race, that a @@ -11,9 +14,18 @@ const state = vi.hoisted(() => ({ roster: undefined as unknown, module: undefined as unknown, propose: undefined as unknown as ReturnType, + db: undefined as unknown as FakeAnalyticsDb, + headers: {} as Record, })) -vi.mock('../server/analytics', () => ({ logRace: async () => undefined })) +vi.mock('../server/db', () => ({ + databaseConfigured: () => true, + getPool: () => ({ + query: async (sql: string, params?: unknown[]) => state.db.query(sql, params), + }), + withTimeout: (work: Promise): Promise => work, +})) +vi.mock('next/headers', () => ({ headers: async () => new Headers(state.headers) })) const enforceRateLimit = vi.fn(async () => undefined) vi.mock('../server/rate-limit', () => ({ enforceRateLimit })) vi.mock('../agents/registry', () => ({ getRoster: () => state.roster })) @@ -61,6 +73,11 @@ async function frames(res: Response): Promise[]> { } beforeEach(() => { + state.db = fakeAnalyticsDb() + state.headers = {} + delete (globalThis as { intentAnalyticsSchema?: unknown }).intentAnalyticsSchema + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'testnet') + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORKS', '') state.propose = vi.fn(async () => ({ ok: false, error: 'boom' })) state.roster = [ { @@ -76,6 +93,7 @@ beforeEach(() => { }) afterEach(() => { + vi.unstubAllEnvs() vi.resetModules() }) @@ -186,3 +204,166 @@ describe('POST /api/agents/compete on a chain', () => { }) }) }) + +const raceInput = (chain?: string) => ({ + id: '22222222-2222-4222-8222-222222222222', + network: 'mainnet' as const, + ...(chain === undefined ? {} : { chain }), + startedAt: new Date(Date.now() - 60_000), + durationMs: 1000, + intent: { type: 'swap', tokenIn: 'NEAR', tokenOut: 'USDC', sizeUsd: 10 }, + attempts: [{ agent: 'halcyon', model: 'm', ok: true as const, latencyMs: 900 }], + scores: { halcyon: 90 }, + winner: 'halcyon' as string | null, + unanimous: true as boolean | null, +}) + +describe('a race records the chain it ran on', () => { + it('writes the chain, and stellar when none is given', async () => { + const { logRace } = await import('../server/analytics') + await logRace(raceInput('near')) + await logRace({ ...raceInput(), id: '33333333-3333-4333-8333-333333333333' }) + expect(state.db.races.map((r) => r.chain)).toEqual(['near', 'stellar']) + }) + + it('is written by the compete route for a chain action, and for stellar', async () => { + const { POST } = await import('../../app/api/agents/compete/route') + await ( + await POST( + post({ text: 'swap 5 NEAR to USDC', chain: 'near', account: 'a.near', chainAction: action }) + ) + ).text() + await (await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar' }))).text() + expect(state.db.races.map((r) => [r.chain, r.network])).toEqual([ + ['near', 'mainnet'], + ['stellar', 'testnet'], + ]) + }) +}) + +describe('GET /api/agents/history', () => { + const stellar = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7)) + const accounts: [string, string][] = [ + ['stellar', stellar], + ['solana', 'So11111111111111111111111111111111111111112'], + ['sui', `0x${'a'.repeat(64)}`], + ['near', 'alice.near'], + ['evm', `0x${'b'.repeat(40)}`], + ] + const get = (account: string | null): Request => + new Request( + `http://localhost/api/agents/history${account === null ? '' : `?account=${encodeURIComponent(account)}`}` + ) + + async function seedExecution(account: string, network: 'mainnet' | 'testnet'): Promise { + const { logRace, getAnalyticsRepo } = await import('../server/analytics') + await logRace({ ...raceInput(), network }) + await ( + await getAnalyticsRepo() + ).recordExecution({ + network, + account, + kind: 'swap', + feeSponsored: false, + ok: true, + hash: 'HASH', + raceId: raceInput().id, + }) + } + + it.each(accounts)('answers a %s account its own races', async (name, account) => { + await seedExecution(account, name === 'stellar' ? 'testnet' : 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const res = await GET(get(account)) + expect(res.status).toBe(200) + const body = (await res.json()) as { entries: { executedHash: string }[] } + expect(body.entries.map((e) => e.executedHash)).toEqual(['HASH']) + }) + + it('never answers another account the races of this one', async () => { + await seedExecution('alice.near', 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get('bob.near'))).json()) as { entries: unknown[] } + expect(body.entries).toEqual([]) + }) + + it.each([ + ['no account', null], + ['text that is no address', 'not an address'], + ['a stellar address with a bad checksum', `G${'A'.repeat(55)}`], + ])('answers invalid_account for %s', async (_name, account) => { + const { GET } = await import('../../app/api/agents/history/route') + const res = await GET(get(account)) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'invalid_account' }) + }) +}) + +describe('POST /api/chain/near/submit and the race it came from', () => { + const RACE = raceInput().id + const submit = vi.fn(async () => ({ ok: true, hash: 'H1' })) + + const send = async (): Promise => { + const { POST } = await import('../../app/api/chain/[chain]/submit/route') + return POST( + new Request('http://localhost/api/chain/near/submit', { + method: 'POST', + body: JSON.stringify({ + account: 'alice.near', + venueId: 'ref', + signed: { family: 'near', hashes: ['h1'] }, + }), + }), + { params: Promise.resolve({ chain: 'near' }) } + ) + } + + beforeEach(() => { + state.module = { + chain: 'near', + venues: [{ id: 'ref', assertSafe: () => undefined }], + submit, + } + }) + + async function seedRace(chain: string): Promise { + const { logRace } = await import('../server/analytics') + await logRace(raceInput(chain)) + } + + it('links the execution to the race it carries', async () => { + await seedRace('near') + state.headers = { 'x-intent-race': RACE } + const res = await send() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) + expect(state.db.executions.map((e) => [e.kind, e.race_id])).toEqual([['near', RACE]]) + }) + + it('does not link a race that ran on another chain', async () => { + await seedRace('solana') + state.headers = { 'x-intent-race': RACE } + expect((await send()).status).toBe(200) + expect(state.db.executions.map((e) => e.race_id)).toEqual([null]) + }) + + it.each([ + ['no race id', undefined], + ['a malformed race id', "x'; drop table agent_races"], + ])('answers the same, with no link, for %s', async (_name, header) => { + await seedRace('near') + state.headers = header === undefined ? {} : { 'x-intent-race': header } + const res = await send() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) + expect(state.db.executions.map((e) => e.race_id)).toEqual([null]) + }) + + it('answers as it would without a database when linking fails', async () => { + state.db.down = new Error('connection refused') + state.headers = { 'x-intent-race': RACE } + const res = await send() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) + }) +}) diff --git a/apps/dapp/lib/__tests__/fakes/analytics-db.ts b/apps/dapp/lib/__tests__/fakes/analytics-db.ts index a4ac272d..67cbef5f 100644 --- a/apps/dapp/lib/__tests__/fakes/analytics-db.ts +++ b/apps/dapp/lib/__tests__/fakes/analytics-db.ts @@ -52,6 +52,7 @@ export interface FakeRace { unanimous: boolean | null outcome: string duration_ms: number + chain: string } export interface FakeProposal { @@ -110,9 +111,21 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { return { rows: [] } } - const matchRace = (id: string | null, network: string, at: string): string | null => { + const matchRace = ( + id: string | null, + network: string, + at: string, + chain: string + ): string | null => { const race = races.find((r) => r.id === id) - if (race === undefined || race.network !== network || race.winner === null) return null + if ( + race === undefined || + race.network !== network || + race.chain !== chain || + race.winner === null + ) { + return null + } const age = new Date(at).getTime() - new Date(race.started_at).getTime() return age >= 0 && age < 30 * 60_000 ? race.id : null } @@ -133,6 +146,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { amountIn, volumeUsd, raceId, + raceChain, ] = p as [ string, string, @@ -148,6 +162,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { string | null, number | null, string | null, + string, ] const duplicate = hash !== null && executions.some((e) => e.network === network && e.hash === hash) @@ -166,7 +181,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { asset_out: assetOut, amount_in: amountIn, volume_usd: volumeUsd, - race_id: matchRace(raceId, network, submittedAt), + race_id: matchRace(raceId, network, submittedAt, raceChain), }) return { rows: [{ id }] } } @@ -212,6 +227,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { unanimous, outcome, durationMs, + chain, ] = p as [ string, string, @@ -226,6 +242,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { boolean | null, string, number, + string, ] if (races.some((r) => r.id === id)) return { rows: [] } races.push({ @@ -242,6 +259,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { unanimous, outcome, duration_ms: durationMs, + chain, }) return { rows: [{ id }] } } diff --git a/apps/dapp/lib/server/analytics.ts b/apps/dapp/lib/server/analytics.ts index 28baf98a..acd096d5 100644 --- a/apps/dapp/lib/server/analytics.ts +++ b/apps/dapp/lib/server/analytics.ts @@ -95,6 +95,8 @@ export const ANALYTICS_DDL: readonly string[] = [ // free text from every model on every race rather than the one plan a // user's funds actually moved on. `ALTER TABLE agent_proposals ADD COLUMN IF NOT EXISTS reasoning TEXT`, + // Races from before other chains ran are all Stellar's, which the default says. + `ALTER TABLE agent_races ADD COLUMN IF NOT EXISTS chain TEXT NOT NULL DEFAULT 'stellar'`, ] export type ExecutionKind = @@ -141,6 +143,13 @@ export interface SubmitLike { code?: string } +const OTHER_CHAINS: readonly ExecutionKind[] = ['solana', 'sui', 'near', 'avalanche', 'monad'] + +/** The chain an execution happened on, which is what a race it follows must have run on. */ +function raceChainOf(kind: ExecutionKind): string { + return OTHER_CHAINS.includes(kind) ? kind : 'stellar' +} + const CODE = /^[a-z][a-z0-9_]{0,39}$/ /** @@ -199,6 +208,8 @@ export interface RaceAttemptFailed { export interface RaceInput { id: string network: StellarNetworkName + /** The chain the race ran on; Stellar when omitted. */ + chain?: string startedAt: Date durationMs: number intent: { type: string; tokenIn: string; tokenOut: string; sizeUsd: number | undefined } @@ -211,6 +222,7 @@ export interface RaceInput { export interface RaceRecord { id: string network: StellarNetworkName + chain: string startedAt: Date intentType: string tokenIn: string @@ -247,6 +259,7 @@ export function raceRecordFrom(input: RaceInput): RaceRecord { return { id: input.id, network: input.network, + chain: input.chain ?? 'stellar', startedAt: input.startedAt, intentType: input.intent.type, tokenIn: input.intent.tokenIn, @@ -354,6 +367,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, (SELECT id FROM agent_races WHERE id = $14::uuid AND network = $2 AND winner IS NOT NULL + AND chain = $15 AND started_at <= $9::timestamptz AND started_at > $9::timestamptz - interval '30 minutes')) ON CONFLICT DO NOTHING`, @@ -372,6 +386,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { r.flow?.amountIn ?? null, r.flow?.volumeUsd ?? null, r.raceId ?? null, + raceChainOf(r.kind), ] ) }, @@ -399,8 +414,8 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { await query( `INSERT INTO agent_races (id, network, started_at, intent_type, token_in, token_out, size_usd, - agents, answered, winner, unanimous, outcome, duration_ms) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + agents, answered, winner, unanimous, outcome, duration_ms, chain) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT DO NOTHING`, [ r.id, @@ -416,6 +431,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { r.unanimous, r.outcome, r.durationMs, + r.chain, ] ) for (const p of r.proposals) { diff --git a/packages/db/migrations/014_agent_races_chain.sql b/packages/db/migrations/014_agent_races_chain.sql new file mode 100644 index 00000000..a77164c5 --- /dev/null +++ b/packages/db/migrations/014_agent_races_chain.sql @@ -0,0 +1,5 @@ +-- The chain each agent race ran on. Races from before other chains ran were all Stellar's. +-- The application runs the same statement on first use; keep this in sync with +-- ANALYTICS_DDL in apps/dapp/lib/server/analytics.ts. + +ALTER TABLE agent_races ADD COLUMN IF NOT EXISTS chain TEXT NOT NULL DEFAULT 'stellar'; From b77e5778d64035f70ab5db7cc8bc46825d108718 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:31:02 +0100 Subject: [PATCH 18/24] fix(dapp): read no stellar network when loading the chain name list Walking every chain descriptor at import read Stellar's live descriptor, so the compete and prepare routes threw on load on a deployment serving both networks. --- apps/dapp/lib/chains/prepare.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts index f0a3a085..f78877a0 100644 --- a/apps/dapp/lib/chains/prepare.ts +++ b/apps/dapp/lib/chains/prepare.ts @@ -11,9 +11,8 @@ import { type PreparedAction, } from './types' -const CHAIN_NAMES = new Set( - Object.values(CHAIN_DESCRIPTORS).flatMap((chain) => [chain.slug, chain.name.toLowerCase()]) -) +/** Keys only: each chain's name is its slug, and reading Stellar's descriptor at load would throw. */ +const CHAIN_NAMES = new Set(Object.keys(CHAIN_DESCRIPTORS)) /** * The venue a request names, in lower case. "swap 10 USDC to MON on monad" reads "monad" as one, From c80ef06c0a5aee8dbc80929c83b5894f63cb8fc7 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:36:43 +0100 Subject: [PATCH 19/24] fix(dapp): send a chain submit's own race id and match evm accounts by any casing A chain submit now carries the race it was chosen from, on a single-network deployment too, and none after the direct fallback. History finds an evm account whatever its casing. --- apps/dapp/components/intents/intent-chat.tsx | 1 + apps/dapp/hooks/use-chain-action.ts | 33 ++++--- apps/dapp/hooks/use-competition.ts | 6 ++ .../__tests__/agents-chain-compete.test.ts | 85 +++++++++++++++++++ apps/dapp/lib/__tests__/fakes/analytics-db.ts | 9 +- apps/dapp/lib/agents/chain-race.ts | 7 +- apps/dapp/lib/api/network-fetch.ts | 6 +- apps/dapp/lib/chains/signing-flow.ts | 9 +- apps/dapp/lib/server/analytics.ts | 8 +- 9 files changed, 146 insertions(+), 18 deletions(-) diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index b91a61e4..51e0c562 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -1869,6 +1869,7 @@ export function IntentChat(): JSX.Element { text: chainRace.text, phase: chainAction.phase, signed: chainAction.signed === true, + ...(live.competitionId !== undefined ? { raceId: live.competitionId } : {}), prepare: chainAction.prepare, refuse: chainAction.refuse, }) diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index ac43f139..1dc4a55e 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -35,6 +35,8 @@ export interface ChainActionState { error?: string /** The account the payload was built and checked for. */ account?: string + /** The race this action was chosen from; absent for the direct route built when no race gave one. */ + raceId?: string } export interface ChainAction extends ChainActionState { @@ -51,7 +53,7 @@ export interface ChainAction extends ChainActionState { */ requested: boolean release: () => void - prepare: (request: ChainActionRequest, asked: string) => void + prepare: (request: ChainActionRequest, asked: string, raceId?: string) => void /** Shows a message in the card without building anything, for what can't be read. */ refuse: (message: string, asked: string) => void confirm: () => void @@ -90,7 +92,7 @@ export function useChainAction(): ChainAction { }, []) const prepare = useCallback( - (request: ChainActionRequest, asked: string) => { + (request: ChainActionRequest, asked: string, raceId?: string) => { if (address === undefined) { setState({ phase: 'failed', asked, error: 'Connect a wallet first.' }) return @@ -116,7 +118,13 @@ export function useChainAction(): ChainAction { }) return } - setState({ phase: 'review', asked, prepared: body.prepared, account }) + setState({ + phase: 'review', + asked, + prepared: body.prepared, + account, + ...(raceId !== undefined ? { raceId } : {}), + }) } void run().catch(() => { @@ -132,6 +140,7 @@ export function useChainAction(): ChainAction { const builtFor = state.account if (prepared === undefined || builtFor === undefined) return const account = builtFor + const raceId = state.raceId const mine = generation.current const current = (): boolean => generation.current === mine @@ -184,12 +193,16 @@ export function useChainAction(): ChainAction { setSigned(true) setState((s) => ({ ...s, phase: 'submitting' })) - const result = await submitSigned(`/api/chain/${slug}/submit`, { - account, - venueId: prepared.venueId, - signed: step.signed, - ...(prepared.context !== undefined ? { context: prepared.context } : {}), - }) + const result = await submitSigned( + `/api/chain/${slug}/submit`, + { + account, + venueId: prepared.venueId, + signed: step.signed, + ...(prepared.context !== undefined ? { context: prepared.context } : {}), + }, + raceId + ) if (!current()) return const done = afterSubmitting(result, family) if (!done.latched) setSigned(false) @@ -214,7 +227,7 @@ export function useChainAction(): ChainAction { "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", })) }) - }, [state.prepared, state.account, adapter, slug]) + }, [state.prepared, state.account, state.raceId, adapter, slug]) return { ...state, signed, requested, release, prepare, refuse, confirm, reset } } diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts index 09a27ee9..68b9f80f 100644 --- a/apps/dapp/hooks/use-competition.ts +++ b/apps/dapp/hooks/use-competition.ts @@ -46,6 +46,8 @@ export interface CompetitionWithRoute extends CompetitionState { * these, choosing any other agent silently signed the winner's trade. */ routesByAgent: Record + /** The id the server gave this race once it began. */ + competitionId?: string } export function useCompetition( @@ -94,12 +96,14 @@ export function useCompetition( const [routesByAgent, setRoutesByAgent] = useState>({}) const [agents, setAgents] = useState([]) const [routeCount, setRouteCount] = useState(undefined) + const [competitionId, setCompetitionId] = useState(undefined) // The same list, readable inside the stream loop's closures without a // stale render's copy. const agentsRef = useRef([]) const lastRevealRef = useRef(0) useEffect(() => { + setCompetitionId(undefined) if (parsed === null && chainRace === undefined) { setRouteCount(undefined) setProposals({}) @@ -265,6 +269,7 @@ export function useCompetition( // visible before any of it has answered. agentsRef.current = frame.agents setAttributedRace(frame.competitionId) + setCompetitionId(frame.competitionId) setAgents(frame.agents) setRouteCount(frame.routeCount) continue @@ -389,6 +394,7 @@ export function useCompetition( winner, unanimous, routesByAgent, + ...(competitionId !== undefined ? { competitionId } : {}), ...(routeCount !== undefined ? { routeCount } : {}), ...(error !== undefined ? { error } : {}), ...(route !== undefined ? { route } : {}), diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index 10bdea9b..acf6852f 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -1,6 +1,9 @@ import { StrKey } from '@stellar/stellar-sdk' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { networkFetch, setAttributedRace } from '../api/network-fetch' +import { reviewChainRoute } from '../agents/chain-race' +import { submitSigned } from '../chains/signing-flow' import { fakeAnalyticsDb, type FakeAnalyticsDb } from './fakes/analytics-db' /** @@ -280,6 +283,23 @@ describe('GET /api/agents/history', () => { expect(body.entries.map((e) => e.executedHash)).toEqual(['HASH']) }) + it.each([ + ['a mixed-case read of a lower-case row', `0x${'ab'.repeat(20)}`, `0x${'Ab'.repeat(20)}`], + ['a lower-case read of a mixed-case row', `0x${'Ab'.repeat(20)}`, `0x${'ab'.repeat(20)}`], + ])('finds an evm account by %s', async (_name, logged, asked) => { + await seedExecution(logged, 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get(asked))).json()) as { entries: unknown[] } + expect(body.entries).toHaveLength(1) + }) + + it('keeps a case-sensitive account exact', async () => { + await seedExecution('Alice.near', 'mainnet') + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get('alice.near'))).json()) as { entries: unknown[] } + expect(body.entries).toEqual([]) + }) + it('never answers another account the races of this one', async () => { await seedExecution('alice.near', 'mainnet') const { GET } = await import('../../app/api/agents/history/route') @@ -367,3 +387,68 @@ describe('POST /api/chain/near/submit and the race it came from', () => { expect(await res.json()).toEqual({ ok: true, hash: 'H1' }) }) }) + +describe('the race id on a chain submit, with one network served', () => { + const RACE = raceInput().id + const seen: Headers[] = [] + beforeEach(() => { + seen.length = 0 + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + seen.push(new Headers(init?.headers)) + return new Response(JSON.stringify({ ok: true })) + }) + }) + afterEach(() => { + vi.unstubAllGlobals() + setAttributedRace(undefined) + }) + + it('is sent by the chain submit itself', async () => { + await submitSigned('/api/chain/near/submit', {}, RACE) + expect(seen[0]?.get('x-intent-race')).toBe(RACE) + }) + + it('is absent for an action that came from no race', async () => { + await submitSigned('/api/chain/near/submit', {}) + expect(seen[0]?.get('x-intent-race')).toBeNull() + }) + + it('is credited from the route Review was pressed on', () => { + const prepare = vi.fn() + const route = { kind: 'chain', request: action, venueId: 'ref', venueName: 'Ref' } + const review = (raceId?: string) => + reviewChainRoute({ + route, + asked: action as never, + text: 't', + phase: 'idle', + ...(raceId !== undefined ? { raceId } : {}), + prepare, + refuse: vi.fn(), + }) + review(RACE) + review() + expect(prepare.mock.calls.map((c) => c.length)).toEqual([3, 2]) + expect(prepare.mock.calls[0]?.[2]).toBe(RACE) + }) + + it('is not added by the patched fetch, so an earlier race is never put on a later chain action', async () => { + const sent: Headers[] = [] + const patched = networkFetch( + (async (_u: unknown, init?: RequestInit) => { + sent.push(new Headers(init?.headers)) + return new Response('{}') + }) as typeof fetch, + 'mainnet', + 'https://app.test' + ) + setAttributedRace('earlier-race') + await patched('https://app.test/api/chain/near/submit', { method: 'POST' }) + await patched('https://app.test/api/chain/near/submit', { + method: 'POST', + headers: { 'x-intent-race': RACE }, + }) + await patched('https://app.test/api/swap/submit', { method: 'POST' }) + expect(sent.map((h) => h.get('x-intent-race'))).toEqual([null, RACE, 'earlier-race']) + }) +}) diff --git a/apps/dapp/lib/__tests__/fakes/analytics-db.ts b/apps/dapp/lib/__tests__/fakes/analytics-db.ts index 67cbef5f..cb3dd98c 100644 --- a/apps/dapp/lib/__tests__/fakes/analytics-db.ts +++ b/apps/dapp/lib/__tests__/fakes/analytics-db.ts @@ -303,7 +303,14 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { if (sql.startsWith('SELECT r.id AS race_id')) { const [network, account, limit, offset] = p as [string, string, number, number] const rows = executions - .filter((e) => e.network === network && e.account === account && e.race_id !== null) + .filter( + (e) => + e.network === network && + (e.account === account || + (/^0x[0-9a-fA-F]{40}$/.test(account) && + e.account.toLowerCase() === account.toLowerCase())) && + e.race_id !== null + ) .map((e) => { const race = races.find((r) => r.id === e.race_id) if (race === undefined) return undefined diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index 1a0f6016..0c499b05 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -122,7 +122,9 @@ export function reviewChainRoute(args: { phase: ChainActionPhaseName /** A signature was given for this race. */ signed?: boolean - prepare: (request: ChainActionRequest, asked: string) => void + /** The race this route was proposed in, so the signed action can be credited to it. */ + raceId?: string + prepare: (request: ChainActionRequest, asked: string, raceId?: string) => void refuse: (message: string, asked: string) => void }): ReviewOutcome { const { phase } = args @@ -141,7 +143,8 @@ export function reviewChainRoute(args: { ) return 'refused' } - args.prepare(request, args.text) + if (args.raceId === undefined) args.prepare(request, args.text) + else args.prepare(request, args.text, args.raceId) return 'prepared' } diff --git a/apps/dapp/lib/api/network-fetch.ts b/apps/dapp/lib/api/network-fetch.ts index c0366c8c..36fa374b 100644 --- a/apps/dapp/lib/api/network-fetch.ts +++ b/apps/dapp/lib/api/network-fetch.ts @@ -2,7 +2,7 @@ import { NETWORK_HEADER, type StellarNetworkName } from '@intent/config' import { WALLET_HEADER } from '../wallet-header' -const RACE_HEADER = 'x-intent-race' +export const RACE_HEADER = 'x-intent-race' /** * Tells the server which Stellar network a page is on. @@ -49,7 +49,9 @@ export function networkFetch( new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) headers.set(NETWORK_HEADER, network) if (connectedWallet !== undefined) headers.set(WALLET_HEADER, connectedWallet) - if (raceInFlight !== undefined && path.endsWith('/submit')) { + // A chain submit names its own race (see submitSigned), so a race left over from earlier + // can never be put on an action that did not come from it. + if (raceInFlight !== undefined && path.endsWith('/submit') && !path.startsWith('/api/chain/')) { headers.set(RACE_HEADER, raceInFlight) } return base(input, { ...init, headers }) diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index a00e80c6..708811bb 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -1,3 +1,4 @@ +import { RACE_HEADER } from '../api/network-fetch' import type { PreparedAction, SignedOutcome, SignedPayload, SigningPayload } from './types' import { CHECK_WALLET, @@ -62,12 +63,16 @@ export function afterSigning(outcome: SignedOutcome, family: Family): AfterSigni * Hands signed bytes to the server. A dropped connection or an answer that can't be read, such * as the platform's own page when it stops the function, says nothing about whether they went * out, so it comes back as no answer, which `afterSubmitting` reads as pending. + * The race the action came from, when it came from one, rides along so the log can credit it. */ -export async function submitSigned(url: string, body: unknown): Promise { +export async function submitSigned(url: string, body: unknown, raceId?: string): Promise { try { const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(raceId !== undefined ? { [RACE_HEADER]: raceId } : {}), + }, body: JSON.stringify(body), }) return (await res.json()) as unknown diff --git a/apps/dapp/lib/server/analytics.ts b/apps/dapp/lib/server/analytics.ts index acd096d5..02a578d6 100644 --- a/apps/dapp/lib/server/analytics.ts +++ b/apps/dapp/lib/server/analytics.ts @@ -471,6 +471,10 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { * there is nothing to explain to this wallet about a plan it never * acted on, winning or not. * + * An EVM address matches whatever its casing, since a wallet may report it checksummed + * on one call and lower-case on another; every other chain's address is exact, as + * Solana and Sui addresses are case-sensitive. + * * `address` scopes by equality on an indexed column — the same trust * model every other wallet-scoped read in this app uses (naira orders, * swap history): whoever knows the address can read what it did. That @@ -485,7 +489,9 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { FROM usage_executions e JOIN agent_races r ON r.id = e.race_id LEFT JOIN agent_proposals p ON p.race_id = r.id AND p.won - WHERE e.network = $1 AND e.account = $2 + WHERE e.network = $1 + AND (e.account = $2 + OR ($2 ~ '^0x[0-9a-fA-F]{40}$' AND lower(e.account) = lower($2))) ORDER BY r.started_at DESC LIMIT $3 OFFSET $4`, [network, account, limit + 1, offset] From d39649732d6476d020c1847a9c9741ec329289e0 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:02:04 +0100 Subject: [PATCH 20/24] fix(dapp): hold the card's venue on a second press and name the venue and rate in the race panel Pressing Review or Selected for the venue already on the card built a second deposit address. It is now ignored, and another venue can still replace the card. The plan tag read a source chain routes don't have, so "via Jupiter" never showed. It falls back to the route's venue name. A route with no rate tied the best rate and could be recommended for a measured fill it didn't have. It now scores below every route with a rate, and the recommendation names the rate or says it is unknown. Stellar passes no rates and has no figures, so its scores and its sentence are as they were. --- .../components/intents/competition-panel.tsx | 7 +- apps/dapp/components/intents/intent-chat.tsx | 1 + apps/dapp/hooks/use-competition.ts | 6 +- .../lib/__tests__/agents-chain-prompt.test.ts | 36 ++++++++++ .../lib/__tests__/agents-chain-race.test.ts | 68 ++++++++++++++++++- apps/dapp/lib/agents/chain-race.ts | 28 +++++++- apps/dapp/lib/agents/plan-scoring.ts | 23 +++++-- 7 files changed, 156 insertions(+), 13 deletions(-) diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index 3e02f88c..699b2dd3 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -5,7 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react' import type { CompetitionState } from '../../lib/agents/competition' -import { panelErrorMessage, singleRouteNote } from '../../lib/agents/chain-race' +import { panelErrorMessage, recommendationLine, singleRouteNote } from '../../lib/agents/chain-race' import { describePlan } from '../../lib/agents/competition' import { AgentAvatar } from '../agents/agent-avatar' import { agentBubble, systemPill } from './bubble' @@ -332,10 +332,11 @@ export function CompetitionPanel({ {/* Recommended on the measured fill, which is the only thing scored. "Strongest strategy" implied a judgement of the reasoning; there is none — the route's real output decides, and a tie is broken - by a draw the agents cannot influence. */} + by a draw the agents cannot influence. A supply or a stake is + ranked on its rate instead, and the line says that rate. */} {unanimous ? `All ${Object.values(proposals).filter((p) => p.failed === undefined).length} agents agree: ${describePlan(proposals[winner] ?? {})}. Pick any to execute.` - : `Recommended: ${proposals[winner]?.name}, best measured fill. You pick who executes.`} + : recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)} ) : null}
diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 51e0c562..c6c2b4af 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -1869,6 +1869,7 @@ export function IntentChat(): JSX.Element { text: chainRace.text, phase: chainAction.phase, signed: chainAction.signed === true, + preparedVenueId: chainAction.prepared?.venueId, ...(live.competitionId !== undefined ? { raceId: live.competitionId } : {}), prepare: chainAction.prepare, refuse: chainAction.refuse, diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts index 68b9f80f..f38298ab 100644 --- a/apps/dapp/hooks/use-competition.ts +++ b/apps/dapp/hooks/use-competition.ts @@ -12,7 +12,7 @@ import type { import { DECIDE_AT, RACE_DURATION, WINDOW_SECONDS, revealFloor } from '../lib/agents/competition' import { decodeFrame } from '../lib/agents/events' import type { ChainActionRequest } from '../lib/chains/types' -import { routeFigures } from '../lib/agents/chain-race' +import { routeFigures, routeSource } from '../lib/agents/chain-race' import { setAttributedRace } from '../lib/api/network-fetch' import { planDecision, planReveal } from '../lib/agents/pacing' import type { ParsedIntent } from '../lib/parse-intent' @@ -292,7 +292,7 @@ export function useCompetition( setRoutesByAgent((prev) => ({ ...prev, [key]: frame.route })) } const figures = routeFigures(frame.route, chainRace?.request) - const source = (frame.route as { source?: unknown } | undefined)?.source + const source = routeSource(frame.route) setProposals((prev) => ({ ...prev, @@ -306,7 +306,7 @@ export function useCompetition( vsOraclePct: frame.proposal.projectedSlippagePct, score: 0, reasoning: frame.proposal.reasoning, - ...(typeof source === 'string' ? { source } : {}), + ...(source !== undefined ? { source } : {}), ...(figures !== undefined ? { figures } : {}), sliceCount: frame.proposal.sliceCount, horizonMinutes: frame.proposal.horizonMinutes, diff --git a/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts index fe9b6946..2413ee56 100644 --- a/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-prompt.test.ts @@ -251,6 +251,42 @@ describe('ranking deposits and stakes by rate', () => { const { apyByRoute: _ignored, ...swapPlan } = plan expect(planPenalties({ ...base, routeId: 'b-1' }, swapPlan)).toEqual([]) }) + + it('puts a route with no rate below the worst route with one', () => { + const rated = planScore({ ...base, routeId: 'b-1' }, plan) + const unrated = planScore({ ...base, routeId: 'kamino-1' }, plan) + expect(unrated).toBeLessThan(rated) + expect(planPenalties({ ...base, routeId: 'kamino-1' }, plan)[0]?.reason).toBe('unrated_route') + }) + + it('never lets a route with no rate be crowned over one that shows a rate', () => { + // Several races, because a tie would be broken by a hash of the race's id. + const winners = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map( + (competitionId) => + scoreProposals( + [ + { ...base, agent: 'halcyon', routeId: 'kamino-1' }, + { ...base, agent: 'meridian', routeId: 'a-1' }, + ] as AgentProposalResult[], + { competitionId, plan } + )[0]?.agent + ) + expect(new Set(winners)).toEqual(new Set(['meridian'])) + }) + + it('keeps that order however far apart the rates are', () => { + const wide = { ...plan, apyByRoute: { 'a-1': 250, 'b-1': 4.1 } } + const rated = planScore({ ...base, routeId: 'b-1' }, wide) + const unrated = planScore({ ...base, routeId: 'kamino-1' }, wide) + expect(unrated).toBeLessThan(rated) + }) + + it('leaves the routes level when none of them has a rate', () => { + const { apyByRoute: _ignored, ...noRates } = plan + expect(planScore({ ...base, routeId: 'kamino-1' }, noRates)).toBe(100) + expect(planPenalties({ ...base, routeId: 'kamino-1' }, noRates)).toEqual([]) + expect(planPenalties({ ...base, routeId: 'kamino-1' }, { ...plan, apyByRoute: {} })).toEqual([]) + }) }) describe('what the panel is shown for a chain route', () => { diff --git a/apps/dapp/lib/__tests__/agents-chain-race.test.ts b/apps/dapp/lib/__tests__/agents-chain-race.test.ts index 1bc92d50..98c64452 100644 --- a/apps/dapp/lib/__tests__/agents-chain-race.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-race.test.ts @@ -7,18 +7,24 @@ import { chainSigning, isChainRoute, needsDirectRoute, + recommendationLine, reviewChainRoute, routeForSwap, routeFigures, + routeSource, selectionHolds, showDirectNote, singleRouteNote, } from '../agents/chain-race' import type { ChainRouteQuote } from '../agents/chain-routes' +import { describePlan } from '../agents/competition' import type { ChainActionRequest } from '../chains/types' const asked: ChainActionRequest = { kind: 'swap', amount: '5', tokenIn: 'NEAR', tokenOut: 'USDC' } +const tagged = (source: string | undefined): { source?: string } => + source === undefined ? {} : { source } + function route(over: Partial = {}, request: ChainActionRequest = asked) { return { kind: 'chain', @@ -102,8 +108,21 @@ describe('reviewChainRoute', () => { expect(args.refuse).not.toHaveBeenCalled() }) - it('may replace a built, unsigned card', () => { - const args = { ...base(), phase: 'review' as const } + it('may replace a built, unsigned card with another venue', () => { + const args = { ...base(), phase: 'review' as const, preparedVenueId: 'other' } + expect(reviewChainRoute(args)).toBe('prepared') + expect(args.prepare).toHaveBeenCalledTimes(1) + }) + + it('builds nothing for the venue already on the card, so its deposit address stays', () => { + const args = { ...base(), phase: 'review' as const, preparedVenueId: 'ref' } + expect(reviewChainRoute(args)).toBe('ignored') + expect(args.prepare).not.toHaveBeenCalled() + expect(args.refuse).not.toHaveBeenCalled() + }) + + it('builds again for the same venue once the card has failed', () => { + const args = { ...base(), phase: 'failed' as const, preparedVenueId: 'ref' } expect(reviewChainRoute(args)).toBe('prepared') }) @@ -261,6 +280,51 @@ describe('reviewChainRoute after a signature', () => { }) }) +describe('routeSource', () => { + it('names the venue of a chain route, which the plan tag then shows', () => { + const jupiter = routeSource(route({ venueName: 'Jupiter' })) + const oneClick = routeSource(route({ venueName: 'NEAR Intents 1Click' })) + expect(describePlan({ executionMode: 'fill', ...tagged(jupiter) })).toBe( + 'fills now via Jupiter' + ) + expect(describePlan({ executionMode: 'fill', ...tagged(oneClick) })).toBe( + 'fills now via NEAR Intents 1Click' + ) + }) + + it('keeps the source of a Stellar quote', () => { + expect(routeSource({ id: 'x', source: 'SDEX' })).toBe('SDEX') + }) + + it('has nothing to say with no route', () => { + expect(routeSource(undefined)).toBeUndefined() + expect(routeSource({ source: 5 })).toBeUndefined() + }) +}) + +describe('recommendationLine', () => { + it('says what Stellar and a priced swap always said', () => { + expect(recommendationLine('Halcyon', undefined)).toBe( + 'Recommended: Halcyon, best measured fill. You pick who executes.' + ) + }) + + it('gives a supply or a stake its rate, and claims no fill', () => { + expect(recommendationLine('Halcyon', { headline: '5.20% APY' })).toBe( + 'Recommended: Halcyon, 5.20% APY. You pick who executes.' + ) + expect(recommendationLine('Halcyon', { headline: 'Rate not available' })).toBe( + 'Recommended: Halcyon, rate not available. You pick who executes.' + ) + }) + + it('claims no measured fill for a swap nobody could price', () => { + expect(recommendationLine('Halcyon', { headline: 'Price unknown' })).toBe( + 'Recommended: Halcyon, price unknown. You pick who executes.' + ) + }) +}) + describe('routeFigures', () => { it('shows an unknown price or rate for a chain proposal with no route', () => { expect(routeFigures(undefined, asked)).toEqual({ headline: 'Price unknown' }) diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts index 0c499b05..008e62e3 100644 --- a/apps/dapp/lib/agents/chain-race.ts +++ b/apps/dapp/lib/agents/chain-race.ts @@ -113,7 +113,8 @@ export type ReviewOutcome = 'prepared' | 'refused' | 'ignored' * A press while a build is under way does nothing, so one press makes one * build (and one deposit address). Once a signature is with the wallet or the network * the route can no longer change. A built card that is not yet signed may be - * replaced by choosing another route. + * replaced by choosing another venue. Choosing its own venue again, from this agent + * or another, builds nothing, so the deposit address on screen is the only one. */ export function reviewChainRoute(args: { route: unknown @@ -122,6 +123,8 @@ export function reviewChainRoute(args: { phase: ChainActionPhaseName /** A signature was given for this race. */ signed?: boolean + /** The venue of the card on screen. */ + preparedVenueId?: string | undefined /** The race this route was proposed in, so the signed action can be credited to it. */ raceId?: string prepare: (request: ChainActionRequest, asked: string, raceId?: string) => void @@ -143,6 +146,7 @@ export function reviewChainRoute(args: { ) return 'refused' } + if (phase === 'review' && request.venue === args.preparedVenueId) return 'ignored' if (args.raceId === undefined) args.prepare(request, args.text) else args.prepare(request, args.text, args.raceId) return 'prepared' @@ -196,6 +200,28 @@ export function routeFigures( return { headline: asked.kind === 'swap' ? 'Price unknown' : 'Rate not available' } } +/** The venue a route executes on: a Stellar quote's source, or a chain route's venue. */ +export function routeSource(route: unknown): string | undefined { + const r = route as { source?: unknown; venueName?: unknown } | null | undefined + const name = r?.source ?? r?.venueName + return typeof name === 'string' ? name : undefined +} + +/** + * The line under a decided race. Only a route with a measured price has a measured fill to be + * best at. Anything else, such as a supply's rate, is named for what it is. + */ +export function recommendationLine( + name: string | undefined, + figures: { headline: string } | undefined +): string { + const why = + figures === undefined + ? 'best measured fill' + : `${figures.headline.charAt(0).toLowerCase()}${figures.headline.slice(1)}` + return `Recommended: ${name}, ${why}. You pick who executes.` +} + /** * The panel's error text, without the claim that nothing was signed once * something may have been. The server words its refusals with that sentence, diff --git a/apps/dapp/lib/agents/plan-scoring.ts b/apps/dapp/lib/agents/plan-scoring.ts index b3cc94fc..f293d6b4 100644 --- a/apps/dapp/lib/agents/plan-scoring.ts +++ b/apps/dapp/lib/agents/plan-scoring.ts @@ -67,6 +67,7 @@ export interface PlanPenalty { | 'slices_without_split' | 'lower_rate' | 'unpriced_route' + | 'unrated_route' /** Points subtracted from the route score. */ points: number /** One line a person can read, naming the figures it was judged on. */ @@ -173,18 +174,32 @@ function followOnPenalties(proposal: AgentProposalResult, context: PlanContext): return out } -/** One point of score per percentage point of APY given up against the best route offered. */ +/** Short of the score a route starts on, so a route with a rate always outranks one without. */ +const MAX_RATE_POINTS = 99 + +/** + * One point of score per percentage point of APY given up against the best route offered. A route + * with no rate, when another has one, can't be said to earn anything, so it cannot win on rate. + */ function ratePenalty(proposal: AgentProposalResult, context: PlanContext): PlanPenalty | undefined { const rates = context.apyByRoute if (rates === undefined || proposal.routeId === undefined) return undefined + const offered = Object.values(rates) + if (offered.length === 0) return undefined const own = rates[proposal.routeId] - if (own === undefined) return undefined - const best = Math.max(...Object.values(rates)) + if (own === undefined) { + return { + reason: 'unrated_route', + points: 100, + detail: 'This route showed no rate, so what it earns is unknown.', + } + } + const best = Math.max(...offered) const gap = best - own if (gap <= 0) return undefined return { reason: 'lower_rate', - points: Number(gap.toFixed(2)), + points: Math.min(Number(gap.toFixed(2)), MAX_RATE_POINTS), detail: `Takes ${own}% APY where ${best}% was offered.`, } } From e2d074ceae5427a744e72085b2ac8281b1f6b4c6 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:03:19 +0100 Subject: [PATCH 21/24] fix(dapp): say which chain each race in an account's history ran on One EVM address can race on Avalanche and Monad, and its history rows couldn't tell the two apart. The read now returns the race's chain. No screen shows it yet. --- .../__tests__/agent-history-client.test.ts | 1 + .../__tests__/agents-chain-compete.test.ts | 30 +++++++++++++++++++ apps/dapp/lib/__tests__/fakes/analytics-db.ts | 1 + apps/dapp/lib/agents/history.ts | 1 + apps/dapp/lib/server/analytics.ts | 5 +++- 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/dapp/lib/__tests__/agent-history-client.test.ts b/apps/dapp/lib/__tests__/agent-history-client.test.ts index 13351441..47d5108a 100644 --- a/apps/dapp/lib/__tests__/agent-history-client.test.ts +++ b/apps/dapp/lib/__tests__/agent-history-client.test.ts @@ -5,6 +5,7 @@ import { agentHistoryByHash, fetchAgentHistory, type AgentHistoryEntry } from '. function entry(overrides: Partial = {}): AgentHistoryEntry { return { raceId: 'r1', + chain: 'stellar', startedAt: '2026-10-07T10:00:00.000Z', tokenIn: 'XLM', tokenOut: 'USDC', diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index acf6852f..040f2449 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -283,6 +283,36 @@ describe('GET /api/agents/history', () => { expect(body.entries.map((e) => e.executedHash)).toEqual(['HASH']) }) + it('says which chain each race ran on, so one evm address can tell its chains apart', async () => { + const account = `0x${'c'.repeat(40)}` + const { logRace, getAnalyticsRepo } = await import('../server/analytics') + const repo = await getAnalyticsRepo() + const races = [ + ['avalanche', '44444444-4444-4444-8444-444444444444'], + ['monad', '55555555-5555-4555-8555-555555555555'], + ] as const + for (const [chain, id] of races) { + await logRace({ ...raceInput(chain), id }) + await repo.recordExecution({ + network: 'mainnet', + account, + kind: chain, + feeSponsored: false, + ok: true, + hash: `HASH-${chain}`, + raceId: id, + }) + } + const { GET } = await import('../../app/api/agents/history/route') + const body = (await (await GET(get(account))).json()) as { + entries: { executedHash: string; chain: string }[] + } + expect(body.entries.map((e) => [e.executedHash, e.chain]).sort()).toEqual([ + ['HASH-avalanche', 'avalanche'], + ['HASH-monad', 'monad'], + ]) + }) + it.each([ ['a mixed-case read of a lower-case row', `0x${'ab'.repeat(20)}`, `0x${'Ab'.repeat(20)}`], ['a lower-case read of a mixed-case row', `0x${'Ab'.repeat(20)}`, `0x${'ab'.repeat(20)}`], diff --git a/apps/dapp/lib/__tests__/fakes/analytics-db.ts b/apps/dapp/lib/__tests__/fakes/analytics-db.ts index cb3dd98c..a8bfb8af 100644 --- a/apps/dapp/lib/__tests__/fakes/analytics-db.ts +++ b/apps/dapp/lib/__tests__/fakes/analytics-db.ts @@ -317,6 +317,7 @@ export function fakeAnalyticsDb(): FakeAnalyticsDb { const winning = proposals.find((pr) => pr.race_id === race.id && pr.won) return { race_id: race.id, + chain: race.chain, started_at: race.started_at, token_in: race.token_in, token_out: race.token_out, diff --git a/apps/dapp/lib/agents/history.ts b/apps/dapp/lib/agents/history.ts index 187b7f6a..675b2edb 100644 --- a/apps/dapp/lib/agents/history.ts +++ b/apps/dapp/lib/agents/history.ts @@ -6,6 +6,7 @@ export interface AgentHistoryEntry { raceId: string + chain: string startedAt: string tokenIn: string tokenOut: string diff --git a/apps/dapp/lib/server/analytics.ts b/apps/dapp/lib/server/analytics.ts index 02a578d6..7e4aa127 100644 --- a/apps/dapp/lib/server/analytics.ts +++ b/apps/dapp/lib/server/analytics.ts @@ -334,6 +334,8 @@ export interface HistoryQuery { export interface HistoryEntry { raceId: string + /** The chain the race ran on, since one EVM address can have raced on several. */ + chain: string startedAt: string tokenIn: string tokenOut: string @@ -484,7 +486,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { const limit = Math.max(1, Math.min(100, pageSize)) const offset = Math.max(0, (Math.max(1, page) - 1) * limit) const { rows } = await query( - `SELECT r.id AS race_id, r.started_at, r.token_in, r.token_out, r.size_usd, + `SELECT r.id AS race_id, r.chain, r.started_at, r.token_in, r.token_out, r.size_usd, r.winner, p.model AS winning_model, p.reasoning, e.hash AS executed_hash FROM usage_executions e JOIN agent_races r ON r.id = e.race_id @@ -498,6 +500,7 @@ export function createAnalyticsRepo(query: QueryFn): AnalyticsRepo { ) const entries = rows.slice(0, limit).map((row) => ({ raceId: String(row['race_id']), + chain: String(row['chain']), startedAt: new Date(row['started_at'] as string).toISOString(), tokenIn: String(row['token_in']), tokenOut: String(row['token_out']), From 5a52aa0b7aaa041dfc378f4b7e9d9bd8956032ee Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:03:59 +0100 Subject: [PATCH 22/24] fix(dapp): read an amount like .5 as 0.5 so its build isn't refused The chat read ".5" but the build route refuses that shape, so "swap .5 USDC to SOL" ended on the raw code invalid_body. Every other amount reads as before, and Stellar's chat doesn't use this parser. --- apps/dapp/lib/__tests__/chain-action.test.ts | 19 +++++++++++++++++++ apps/dapp/lib/chains/parse-action.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts index fccc6c45..201e7582 100644 --- a/apps/dapp/lib/__tests__/chain-action.test.ts +++ b/apps/dapp/lib/__tests__/chain-action.test.ts @@ -28,6 +28,25 @@ describe('parseChainAction', () => { }) }) + it('writes a bare decimal the way the build route and base units accept it', () => { + const amount = parseChainAction('swap .5 USDC to SOL')?.amount as string + expect(amount).toBe('0.5') + expect(toBaseUnits(amount, 6)).toBe('500000') + expect(parseChainAction('supply $.25 USDC')?.amount).toBe('0.25') + expect(parseChainAction('stake .1 NEAR')?.amount).toBe('0.1') + }) + + it.each([ + ['5', '5'], + ['0.5', '0.5'], + ['05', '05'], + ['1,000', '1000'], + ['1,000.5', '1000.5'], + ['$20', '20'], + ])('keeps %s as it read before', (typed, amount) => { + expect(parseChainAction(`swap ${typed} USDC to SOL`)?.amount).toBe(amount) + }) + it('reads a supply and a stake', () => { expect(parseChainAction('supply 100 USDC on Kamino')).toMatchObject({ kind: 'supply', diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index 4e822159..fac32b39 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -22,8 +22,10 @@ const STAKE = new RegExp( const ACTION_VERB = /\b(swap|convert|exchange|trade|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i +/** Plain decimal text: no separators, and a leading zero on ".5", which nothing downstream reads. */ function amountOf(text: string): string { - return text.replace(/,/g, '') + const plain = text.replace(/,/g, '') + return plain.startsWith('.') ? `0${plain}` : plain } /** From 9ac07f9c7c3d1610c593f7e8f38f6855f2bcd5c0 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:06:46 +0100 Subject: [PATCH 23/24] fix(dapp): refuse a race with no venue and stop promising what a chain can't run Asking for something no venue offers ran through "the agents did not race", then the direct route, then a refusal. The race now refuses once, with no fallback. Venues that exist but can't price right now keep today's wording. The capability sheet reads the chain's venues, so with AUTH_SECRET unset Avalanche and Monad no longer claim a race. Solana and Sui still race on their own venues. The sheet's only importer is the orchestrator, which only an API route runs, so nothing server-only reaches a client bundle. With the agents offline off Stellar the message no longer says nothing can be executed, since the direct route is built right after it. --- apps/dapp/.env.example | 3 +- apps/dapp/app/api/agents/compete/route.ts | 11 ++- .../__tests__/agents-chain-compete.test.ts | 76 ++++++++++++++++++- .../orchestrate-capabilities-chain.test.ts | 60 ++++++++++++++- apps/dapp/lib/orchestrate/capabilities.ts | 52 ++++++------- 5 files changed, 172 insertions(+), 30 deletions(-) diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index c1dca7ca..26f715b3 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -69,7 +69,8 @@ MONEYGRAM_PRODUCTION_HOME_DOMAIN= # the transaction pooler, port 6543); localhost only works on your own machine. DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent # Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -# Also signs the 1Click deposits issued on Solana and Sui. Without it that route isn't offered. +# Also signs the 1Click deposits issued on Solana, Sui, Avalanche and Monad. Without it that route +# isn't offered, which leaves Avalanche and Monad with no swaps at all. AUTH_SECRET= # on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only # ACCESS_GATE=on turns it on, on any network. The API is outside diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 2420b0ee..534d6017 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -127,6 +127,12 @@ async function chainRaceFacts( account: string | undefined, text: string ): Promise<{ intent: ParsedIntent; market: MarketContext } | string | { refusal: string }> { + // No venue to quote, and none for the direct route to build on, so nothing is worth trying. + if (!chainApi.venues.some((v) => v.kinds.includes(action.kind))) { + return { + refusal: `Nothing here can ${action.kind} on this chain yet, so nothing was signed or sent.`, + } + } try { const known = assertKnownTokens(chainApi, action) await assertAmountWithinCap(chainApi, action, known) @@ -337,7 +343,10 @@ export async function POST(request: Request): Promise { if (full === undefined) { return errorStream( 'agents_offline', - 'The agents are not online right now, so nothing was proposed. Nothing can be executed until they are.' + // Off Stellar the chat builds the direct route next, so something can still be signed. + chain === 'stellar' + ? 'The agents are not online right now, so nothing was proposed. Nothing can be executed until they are.' + : 'The agents are not online right now, so nothing was proposed.' ) } const roster = lineUp(full) diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts index 040f2449..f4992c0d 100644 --- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts +++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts @@ -2,7 +2,7 @@ import { StrKey } from '@stellar/stellar-sdk' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { networkFetch, setAttributedRace } from '../api/network-fetch' -import { reviewChainRoute } from '../agents/chain-race' +import { needsDirectRoute, reviewChainRoute } from '../agents/chain-race' import { submitSigned } from '../chains/signing-flow' import { fakeAnalyticsDb, type FakeAnalyticsDb } from './fakes/analytics-db' @@ -170,6 +170,80 @@ describe('POST /api/agents/compete on a chain', () => { expect(state.propose).not.toHaveBeenCalled() }) + describe('with no venue on the chain for what was asked', () => { + const noVenue = (venues: unknown[]) => ({ ...(nearModule(vi.fn()) as object), venues }) + + it('refuses in one sentence, so nothing races and nothing falls back', async () => { + const quote = vi.fn() + state.module = noVenue([{ id: 'ref', name: 'Ref', kinds: ['swap'], quote }]) + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST( + post({ + text: 'supply 5 NEAR', + chain: 'near', + account: 'a.near', + chainAction: { kind: 'supply', amount: '5', tokenIn: 'NEAR' }, + }) + ) + ) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ + type: 'competition:error', + code: 'action_refused', + message: 'Nothing here can supply on this chain yet, so nothing was signed or sent.', + }) + expect( + needsDirectRoute({ + phase: 'decided', + winner: null, + anyAnswered: false, + error: out[0] as never, + }) + ).toBe(false) + expect(quote).not.toHaveBeenCalled() + expect(state.propose).not.toHaveBeenCalled() + }) + + it('says so before looking up a price, as a chain with no venues has no reason to', async () => { + const priceUsd = vi.fn(async () => undefined) + state.module = { ...noVenue([]), priceUsd } + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST(post({ text: 'swap', chain: 'near', account: 'a.near', chainAction: action })) + ) + expect(out[0]?.message).toBe( + 'Nothing here can swap on this chain yet, so nothing was signed or sent.' + ) + expect(priceUsd).not.toHaveBeenCalled() + }) + }) + + describe('with the agents offline', () => { + const LAST_SENTENCE = 'Nothing can be executed until they are.' + + it('does not say nothing can run where the direct route is built next', async () => { + state.roster = undefined + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames( + await POST(post({ text: 'swap', chain: 'near', account: 'a.near', chainAction: action })) + ) + expect(out[0]).toMatchObject({ + code: 'agents_offline', + message: 'The agents are not online right now, so nothing was proposed.', + }) + }) + + it('says what it always said on stellar', async () => { + state.roster = undefined + const { POST } = await import('../../app/api/agents/compete/route') + const out = await frames(await POST(post({ text: 'swap 10 XLM to USDC', chain: 'stellar' }))) + expect(out[0]?.message).toBe( + `The agents are not online right now, so nothing was proposed. ${LAST_SENTENCE}` + ) + }) + }) + it('rate-limits by an account of 66 characters', async () => { const account = 'a'.repeat(66) const { POST } = await import('../../app/api/agents/compete/route') diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts index fd0e425a..575e44d6 100644 --- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts +++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { capabilitySheet } from '../orchestrate/capabilities' @@ -23,4 +23,62 @@ describe('capabilitySheet per chain', () => { expect(capabilitySheet('sui')).toContain('Agents compete') expect(capabilitySheet('solana')).not.toContain('Stake:') }) + + it('leaves a chain with no module as it was', () => { + expect(capabilitySheet('arc')).toBe( + '- Swap: trade one asset for another at the market price. You review it and sign in your wallet.' + ) + }) +}) + +describe('capabilitySheet reads what the chain can run on this server', () => { + afterEach(() => { + vi.doUnmock('../chains/registry') + vi.unstubAllEnvs() + vi.resetModules() + }) + + /** The real registry, built from scratch with this secret, as a server starting up would. */ + async function sheetWith(secret: string): Promise { + vi.resetModules() + vi.stubEnv('AUTH_SECRET', secret) + return (await import('../orchestrate/capabilities')).capabilitySheet + } + + it('promises nothing on Avalanche and Monad with no secret to issue a deposit', async () => { + const sheet = await sheetWith('') + for (const chain of ['avalanche', 'monad']) { + expect(sheet(chain)).toBe( + '- Nothing can be swapped, supplied or staked on this chain right now.' + ) + } + }) + + it('promises the race on them once the secret is set', async () => { + const sheet = await sheetWith('x'.repeat(48)) + for (const chain of ['avalanche', 'monad']) { + expect(sheet(chain)).toContain('Swap:') + expect(sheet(chain)).toContain('Agents compete') + expect(sheet(chain)).not.toMatch(/Supply:|Stake:/) + } + }, 60_000) + + it('still races Solana and Sui with no secret, on their own venues', async () => { + const sheet = await sheetWith('') + expect(sheet('solana')).toMatch(/Swap:.*Agents compete/) + expect(sheet('sui')).toMatch(/Swap:.*Agents compete/) + }, 60_000) + + it('promises no race for a venue that gives no quote, and says the rest plainly', async () => { + vi.resetModules() + vi.doMock('../chains/registry', () => ({ + chainModule: () => ({ venues: [{ kinds: ['swap'] }, { kinds: ['stake'], quote: vi.fn() }] }), + })) + const { capabilitySheet: sheet } = await import('../orchestrate/capabilities') + const lines = sheet('near').split(/\r?\n/) + expect(lines[0]).toMatch(/^- Swap:.*You review it/) + expect(lines[0]).not.toContain('Agents compete') + expect(lines[1]).toMatch(/^- Stake:.*Agents compete/) + expect(lines).toHaveLength(2) + }) }) diff --git a/apps/dapp/lib/orchestrate/capabilities.ts b/apps/dapp/lib/orchestrate/capabilities.ts index 7386a195..6f4b134b 100644 --- a/apps/dapp/lib/orchestrate/capabilities.ts +++ b/apps/dapp/lib/orchestrate/capabilities.ts @@ -1,6 +1,5 @@ -import type { ChainSlug } from '@intent/types' - -import { chainCapabilities } from '../chains/capabilities' +import { chainModule } from '../chains/registry' +import type { ChainActionKind } from '../chains/types' /** * What Intent does, written down once. @@ -9,15 +8,11 @@ import { chainCapabilities } from '../chains/capabilities' * nothing else. A model left to describe a product from its own idea of one will * promise features it lacks, so every claim it may make about Intent is here, * and the prompt forbids the rest. + * + * Server only: it reads the chains' venues, and whether a chain has any can turn + * on a secret. Its one importer is the orchestrator, which only an API route runs. */ -/** - * Chains where agents race to propose the route. A chain joins this list only - * once its venues can be quoted, so the sheet never promises a race that does - * not run. - */ -const AGENT_RACE_CHAINS = ['stellar', 'near', 'solana', 'sui', 'avalanche', 'monad'] - const SWAP_RACED = 'Swap: trade one asset for another at the market price, or place a limit order at a price you name. Agents compete to propose the best route; you review it and sign in your wallet.' const SWAP_RACED_OFF_STELLAR = @@ -39,32 +34,37 @@ const STELLAR_ONLY = [ 'Each trade is capped at $500 on mainnet.', ] +const NOTHING_RUNS = 'Nothing can be swapped, supplied or staked on this chain right now.' + +function bullets(lines: string[]): string { + return lines.map((l) => `- ${l}`).join('\n') +} + /** The facts about Intent that apply on a chain, one per line. */ export function capabilitySheet(chain: string): string { - // Limit orders are Stellar's; elsewhere a swap is at the market price. - const swap = - chain === 'stellar' - ? SWAP_RACED - : AGENT_RACE_CHAINS.includes(chain) - ? SWAP_RACED_OFF_STELLAR - : SWAP_DIRECT if (chain === 'stellar') { - return [swap, ...COMMON, ...STELLAR_ONLY].map((l) => `- ${l}`).join('\n') + return bullets([SWAP_RACED, ...COMMON, ...STELLAR_ONLY]) } + const venues = chainModule(chain)?.venues + if (venues === undefined) return `- ${SWAP_DIRECT}` + // Off Stellar only what the chain's own venues can do is claimed: accumulate, // hedge and send are Stellar's, and the chat reads only swap, supply and stake. - const kinds = new Set((chainCapabilities(chain as ChainSlug) ?? []).map((c) => c.kind)) - const raced = AGENT_RACE_CHAINS.includes(chain) - const lines = [swap] - if (kinds.has('supply')) { + // A race needs a venue that can quote, so a kind with none is never promised one. + const offered = (kind: ChainActionKind): boolean => venues.some((v) => v.kinds.includes(kind)) + const raced = (kind: ChainActionKind): boolean => + venues.some((v) => v.kinds.includes(kind) && v.quote !== undefined) + const lines: string[] = [] + if (offered('swap')) lines.push(raced('swap') ? SWAP_RACED_OFF_STELLAR : SWAP_DIRECT) + if (offered('supply')) { lines.push( - `Supply: put an asset you hold into a lending or earn venue to earn yield. ${raced ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` + `Supply: put an asset you hold into a lending or earn venue to earn yield. ${raced('supply') ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` ) } - if (kinds.has('stake')) { + if (offered('stake')) { lines.push( - `Stake: stake an asset for a staking token. ${raced ? 'Agents compete to propose the best venue; you' : 'You'} review it and sign in your wallet.` + `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 lines.map((l) => `- ${l}`).join('\n') + return bullets(lines.length > 0 ? lines : [NOTHING_RUNS]) } From c851aa74259c1bd25e064c7ad78c73cebff68f82 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:10:50 +0100 Subject: [PATCH 24/24] fix(dapp): warn about a chain transfer already started, even after a reload The latch that stops a second signature lives in memory, so a reload during a long status wait lost it, and retyping could spend the amount twice. Each signature now leaves a note in localStorage for 15 minutes, and a new card for the same chain, account, token and amount says it was started and links the hash when there is one. It warns and never blocks, and failed storage changes nothing. An answer that proves nothing moved removes the note. Pending and success leave it. The latch rules are as they were. --- .../intents/chain-action-confirm.tsx | 19 +- apps/dapp/hooks/use-chain-action.ts | 33 ++- .../lib/__tests__/chain-signing-flow.test.ts | 222 ++++++++++++++++++ apps/dapp/lib/chains/signing-flow.ts | 188 +++++++++++++++ 4 files changed, 459 insertions(+), 3 deletions(-) diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 24229157..7905ea52 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -7,7 +7,7 @@ import type { ChainAction } from '../../hooks/use-chain-action' /** Review, sign and result for an action on a chain other than Stellar. */ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element { - const { phase, prepared, error, hash, explorerUrl } = action + const { phase, prepared, error, hash, explorerUrl, earlier } = action const busy = phase === 'signing' || phase === 'submitting' return ( @@ -74,6 +74,23 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele ) : null} + {phase === 'review' && earlier !== undefined ? ( +

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

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

{error} diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts index 1dc4a55e..6d9a3e6f 100644 --- a/apps/dapp/hooks/use-chain-action.ts +++ b/apps/dapp/hooks/use-chain-action.ts @@ -7,10 +7,18 @@ import { accountMismatch, afterSigning, afterSubmitting, + attemptNotice, + attemptStorage, + earlierAttempt, latchBeforeSign, + noteSubmitAnswer, + noteWalletAnswer, QUOTE_EXPIRED, quoteExpiredBeforeSigning, + recordAttempt, submitSigned, + type AttemptNotice, + type SignAttempt, } from '../lib/chains/signing-flow' import { useChain } from '../providers/chain-provider' import { useWallet } from './use-wallet' @@ -37,6 +45,8 @@ export interface ChainActionState { account?: string /** The race this action was chosen from; absent for the direct route built when no race gave one. */ raceId?: string + /** This same action was already started recently, perhaps before a reload. */ + earlier?: AttemptNotice } export interface ChainAction extends ChainActionState { @@ -118,12 +128,19 @@ export function useChainAction(): ChainAction { }) return } + const earlier = earlierAttempt(attemptStorage(), { + chain: slug, + account, + tokenIn: body.prepared.pay.symbol, + amount: body.prepared.pay.amount, + }) setState({ phase: 'review', asked, prepared: body.prepared, account, ...(raceId !== undefined ? { raceId } : {}), + ...(earlier !== undefined ? { earlier: attemptNotice(earlier) } : {}), }) } @@ -162,6 +179,15 @@ export function useChainAction(): ChainAction { return } const family = prepared.signing.family + const storage = attemptStorage() + const attempt: SignAttempt = { + chain: slug, + account, + tokenIn: prepared.pay.symbol, + amount: prepared.pay.amount, + at: Date.now(), + } + recordAttempt(storage, attempt) // A wallet that sends for itself may send and then report an error, so // the latch is set before it is called, not after it answers. if (latchBeforeSign(family)) setSigned(true) @@ -172,12 +198,14 @@ export function useChainAction(): ChainAction { }) const outcome = await adapter.signPrepared?.(prepared.signing) + const step = outcome === undefined ? undefined : afterSigning(outcome, family) + // Noted even for a card replaced since: the answer is as true either way. + noteWalletAnswer(storage, attempt, step) if (!current()) return - if (outcome === undefined) { + if (step === undefined) { setState((s) => ({ ...s, phase: 'failed', error: 'This wallet cannot sign here yet.' })) return } - const step = afterSigning(outcome, family) if (step.next === 'review') { setState((s) => ({ ...s, phase: 'review' })) return @@ -203,6 +231,7 @@ export function useChainAction(): ChainAction { }, raceId ) + noteSubmitAnswer(storage, attempt, result) if (!current()) return const done = afterSubmitting(result, family) if (!done.latched) setSigned(false) diff --git a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts index 28146bed..ee5d18e9 100644 --- a/apps/dapp/lib/__tests__/chain-signing-flow.test.ts +++ b/apps/dapp/lib/__tests__/chain-signing-flow.test.ts @@ -1,12 +1,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { evmExplorerUrl } from '../chains/evm/tokens' +import { explorerUrl as nearExplorerUrl } from '../chains/near/tokens' import { accountMismatch, afterSigning, afterSubmitting, + ATTEMPT_WINDOW_MS, + attemptNotice, + earlierAttempt, latchBeforeSign, + noteSubmitAnswer, + noteWalletAnswer, + recordAttempt, submitSigned, + type AfterSigning, + type AttemptStorage, } from '../chains/signing-flow' +import { explorerUrl as solanaExplorerUrl } from '../chains/solana/submit' import { panelErrorMessage, reviewChainRoute } from '../agents/chain-race' import type { ChainActionRequest } from '../chains/types' import { CHECK_WALLET } from '../weave/cashout-view' @@ -225,3 +236,214 @@ describe('panelErrorMessage', () => { expect(panelErrorMessage(said, true)).toBe('No route could be priced right now.') }) }) + +describe('the warning that survives a reload', () => { + const NOW = 1_800_000_000_000 + const MINUTE = 60_000 + const asking = { chain: 'monad', account: '0xabc', tokenIn: 'USDC', amount: '5' } + const attempt = { ...asking, at: NOW } + + function fakeStorage(start: string | null = null): AttemptStorage & { value: string | null } { + const store = { + value: start, + getItem: () => store.value, + setItem: (_key: string, value: string) => { + store.value = value + }, + } + return store + } + + const later = (minutes: number) => NOW + minutes * MINUTE + const started = () => { + const storage = fakeStorage() + recordAttempt(storage, attempt) + return storage + } + + describe('matching', () => { + it('finds an attempt on the same chain, account, token and amount', () => { + expect(earlierAttempt(started(), asking, later(3))).toEqual(attempt) + }) + + it.each([ + ['chain', { chain: 'avalanche' }], + ['account', { account: '0xdef' }], + ['token', { tokenIn: 'USDT0' }], + ['amount', { amount: '6' }], + ])('finds nothing for another %s', (_name, change) => { + expect(earlierAttempt(started(), { ...asking, ...change }, later(3))).toBeUndefined() + }) + + it('reads an EVM address, a token and an amount however they were written', () => { + const same = { ...asking, account: '0xABC', tokenIn: 'usdc', amount: '5.0' } + expect(earlierAttempt(started(), same, later(3))).toEqual(attempt) + }) + + it('keeps case in an address where case is part of it', () => { + const storage = fakeStorage() + recordAttempt(storage, { ...attempt, chain: 'solana', account: 'SoLaNa' }) + const other = { ...asking, chain: 'solana', account: 'solana' } + expect(earlierAttempt(storage, other, later(3))).toBeUndefined() + }) + + it('gives the newest of two', () => { + const storage = started() + recordAttempt(storage, { ...attempt, at: later(2) }) + expect(earlierAttempt(storage, asking, later(3))?.at).toBe(later(2)) + }) + }) + + describe('expiry', () => { + it('holds for 15 minutes and no longer', () => { + expect(earlierAttempt(started(), asking, NOW + ATTEMPT_WINDOW_MS)).toEqual(attempt) + expect(earlierAttempt(started(), asking, NOW + ATTEMPT_WINDOW_MS + 1)).toBeUndefined() + }) + + it('drops expired attempts the next time one is written', () => { + const storage = started() + recordAttempt(storage, { ...attempt, tokenIn: 'MON', at: later(20) }) + expect(JSON.parse(storage.value as string)).toHaveLength(1) + }) + }) + + describe('broken storage changes nothing', () => { + const throwing: AttemptStorage = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('full') + }, + } + + it.each([ + ['that throws', throwing], + ['that is missing', undefined], + ])('storage %s', (_name, storage) => { + expect(() => recordAttempt(storage, attempt)).not.toThrow() + expect(() => noteWalletAnswer(storage, attempt, undefined)).not.toThrow() + expect(() => + noteSubmitAnswer(storage, attempt, { ok: true, hash: 'h', explorerUrl: 'u' }) + ).not.toThrow() + expect(earlierAttempt(storage, asking, later(3))).toBeUndefined() + }) + + it.each([ + ['text that is not JSON', 'not json'], + ['JSON that is not a list', '{"chain":"monad"}'], + ['a list of the wrong things', '[1,null,{"chain":"monad","at":"soon"}]'], + ])('reads %s as nothing, and writes over it', (_name, saved) => { + const storage = fakeStorage(saved) + expect(earlierAttempt(storage, asking, later(3))).toBeUndefined() + recordAttempt(storage, attempt) + expect(earlierAttempt(storage, asking, later(3))).toEqual(attempt) + }) + }) + + describe('what the wallet answers', () => { + it.each([ + ['a decline on a wallet that only signs', { next: 'review' }], + ['an error before anything was sent', { next: 'failed', error: 'no' }], + ['a wallet that cannot sign here', undefined], + ] as [string, AfterSigning | undefined][])('forgets the attempt after %s', (_name, step) => { + const storage = started() + noteWalletAnswer(storage, attempt, step) + expect(earlierAttempt(storage, asking, later(1))).toBeUndefined() + }) + + it('keeps the attempt when a wallet that sends for itself gives no confirmation', () => { + const storage = started() + noteWalletAnswer(storage, attempt, afterSigning({ ok: false, reason: 'rejected' }, 'evm')) + expect(earlierAttempt(storage, asking, later(1))).toEqual(attempt) + }) + + it('adds the hash a wallet returned', () => { + const storage = started() + noteWalletAnswer(storage, attempt, { + next: 'submit', + signed: { family: 'evm', hash: '0xh' }, + }) + expect(earlierAttempt(storage, asking, later(1))?.hash).toBe('0xh') + }) + + it('adds the last of the hashes a NEAR wallet returned, the one its venue follows', () => { + const storage = started() + noteWalletAnswer(storage, attempt, { + next: 'submit', + signed: { family: 'near', hashes: ['first', 'last'] }, + }) + expect(earlierAttempt(storage, asking, later(1))?.hash).toBe('last') + }) + + it('has no hash yet for signed bytes', () => { + const storage = started() + noteWalletAnswer(storage, attempt, { + next: 'submit', + signed: { family: 'solana', signedTransaction: 'x' }, + }) + expect(earlierAttempt(storage, asking, later(1))).toEqual(attempt) + }) + }) + + describe('what the server answers', () => { + it.each([ + ['a clean refusal', { ok: false, error: 'The quote moved too far.' }], + ['a proven revert', { ok: false, error: 'This transfer failed on Monad.' }], + ])('forgets the attempt after %s', (_name, body) => { + const storage = started() + noteSubmitAnswer(storage, attempt, body) + expect(earlierAttempt(storage, asking, later(1))).toBeUndefined() + }) + + it.each([ + ['pending', { ok: false, pending: true, error: 'Still going.' }], + ['no answer at all', undefined], + ['a code from before the venue was reached', { error: 'invalid_body' }], + ])('keeps the attempt after %s', (_name, body) => { + const storage = started() + noteSubmitAnswer(storage, attempt, body) + expect(earlierAttempt(storage, asking, later(1))).toEqual(attempt) + }) + + it('keeps the attempt after success, with the hash, since retyping would pay twice', () => { + const storage = started() + noteSubmitAnswer(storage, attempt, { ok: true, hash: 'h1', explorerUrl: 'u' }) + expect(earlierAttempt(storage, asking, later(1))?.hash).toBe('h1') + }) + + it('forgets only its own attempt', () => { + const storage = started() + const second = { ...attempt, at: later(1) } + recordAttempt(storage, second) + noteSubmitAnswer(storage, second, { ok: false, error: 'The quote moved too far.' }) + expect(earlierAttempt(storage, asking, later(2))).toEqual(attempt) + }) + }) + + describe('what the card says', () => { + it.each([ + [0, 'You started this 1 minute ago, check your wallet first.'], + [1, 'You started this 1 minute ago, check your wallet first.'], + [14, 'You started this 14 minutes ago, check your wallet first.'], + ])('after %i minutes', (minutes, text) => { + expect(attemptNotice(attempt, later(minutes))).toEqual({ text }) + }) + + it.each([ + ['near', nearExplorerUrl('h1')], + ['monad', evmExplorerUrl('monad', 'h1')], + ['avalanche', evmExplorerUrl('avalanche', 'h1')], + ['solana', solanaExplorerUrl('h1')], + ['sui', 'https://suiscan.xyz/mainnet/tx/h1'], + ])('links the hash on %s', (chain, explorerUrl) => { + const notice = attemptNotice({ ...attempt, chain, hash: 'h1' }, later(3)) + expect(notice).toMatchObject({ hash: 'h1', explorerUrl }) + }) + + it('keeps a stored hash inside the path of the explorer', () => { + const notice = attemptNotice({ ...attempt, hash: '../../evil?x=1' }, later(3)) + expect(notice.explorerUrl).toBe(evmExplorerUrl('monad', '..%2F..%2Fevil%3Fx%3D1')) + }) + }) +}) diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts index 708811bb..14575136 100644 --- a/apps/dapp/lib/chains/signing-flow.ts +++ b/apps/dapp/lib/chains/signing-flow.ts @@ -5,8 +5,11 @@ import { accountMismatch, afterSubmit, afterWallet, + depositExplorerUrl, walletSends, } from '../weave/cashout-view' +import { isCashoutChain } from '../weave/types' +import { evmExplorerUrl } from './evm/tokens' type Family = SigningPayload['family'] @@ -113,3 +116,188 @@ export function afterSubmitting(result: unknown, family: Family): AfterSubmittin } return { phase: 'failed', error: step.error, latched: false } } + +/** + * A signature that was asked for, kept past a reload so a person who comes back and types the same + * thing again is warned before paying twice. It warns and never blocks, so storage that fails + * changes nothing. It is dropped only by an answer that proves nothing moved. + */ +export interface SignAttempt { + chain: string + account: string + tokenIn: string + amount: string + at: number + hash?: string +} + +export type AttemptStorage = Pick + +export const ATTEMPT_WINDOW_MS = 15 * 60_000 + +const ATTEMPTS_KEY = 'intent.chain.attempts' + +/** Reading `localStorage` can itself throw when a browser denies it. */ +export function attemptStorage(): AttemptStorage | undefined { + try { + return globalThis.localStorage + } catch { + return undefined + } +} + +function isAttempt(value: unknown): value is SignAttempt { + if (typeof value !== 'object' || value === null) return false + const a = value as Partial + return ( + typeof a.chain === 'string' && + typeof a.account === 'string' && + typeof a.tokenIn === 'string' && + typeof a.amount === 'string' && + typeof a.at === 'number' && + (a.hash === undefined || typeof a.hash === 'string') + ) +} + +function readAttempts(storage: AttemptStorage | undefined, now: number): SignAttempt[] { + try { + const saved: unknown = JSON.parse(storage?.getItem(ATTEMPTS_KEY) ?? '[]') + if (!Array.isArray(saved)) return [] + return saved.filter(isAttempt).filter((a) => now - a.at <= ATTEMPT_WINDOW_MS) + } catch { + return [] + } +} + +function writeAttempts(storage: AttemptStorage | undefined, attempts: SignAttempt[]): void { + try { + storage?.setItem(ATTEMPTS_KEY, JSON.stringify(attempts)) + } catch { + // The warning is an extra, and signing goes on without it. + } +} + +/** Called before the wallet opens. */ +export function recordAttempt(storage: AttemptStorage | undefined, attempt: SignAttempt): void { + writeAttempts(storage, [...readAttempts(storage, attempt.at), attempt]) +} + +/** Whether two records are one attempt. A hash may have been added since. */ +function sameAttempt(a: SignAttempt, b: SignAttempt): boolean { + return ( + a.at === b.at && + a.chain === b.chain && + a.account === b.account && + a.tokenIn === b.tokenIn && + a.amount === b.amount + ) +} + +function forgetAttempt(storage: AttemptStorage | undefined, attempt: SignAttempt): void { + const kept = readAttempts(storage, Date.now()).filter((a) => !sameAttempt(a, attempt)) + writeAttempts(storage, kept) +} + +function addAttemptHash( + storage: AttemptStorage | undefined, + attempt: SignAttempt, + hash: string +): void { + const attempts = readAttempts(storage, Date.now()) + writeAttempts( + storage, + attempts.map((a) => (sameAttempt(a, attempt) ? { ...a, hash } : a)) + ) +} + +function walletHash(signed: SignedPayload): string | undefined { + if (signed.family === 'evm') return signed.hash + if (signed.family === 'near') return signed.hashes[signed.hashes.length - 1] + return undefined +} + +/** + * What the wallet's answer says about the attempt. No step means no wallet here could sign, and a + * declined or failed signed-bytes wallet never sent anything. A wallet that sends for itself and + * gave no confirmation proves nothing, so that attempt stays. + */ +export function noteWalletAnswer( + storage: AttemptStorage | undefined, + attempt: SignAttempt, + step: AfterSigning | undefined +): void { + if (step === undefined || step.next === 'review' || step.next === 'failed') { + forgetAttempt(storage, attempt) + return + } + if (step.next !== 'submit') return + const hash = walletHash(step.signed) + if (hash !== undefined) addAttemptHash(storage, attempt, hash) +} + +/** + * What the server's answer says about the attempt. An answer lacks `pending` only if it proves + * nothing moved, a clean refusal or a proven revert, on every chain. So this reads the answer + * itself, where `afterSubmit` keeps a wallet that sends for itself latched regardless. A success + * stays too, since typing it again would pay twice. + */ +export function noteSubmitAnswer( + storage: AttemptStorage | undefined, + attempt: SignAttempt, + result: unknown +): void { + const body = (typeof result === 'object' && result !== null ? result : {}) as { + ok?: unknown + hash?: unknown + pending?: unknown + } + if (body.ok === false && body.pending !== true) { + forgetAttempt(storage, attempt) + return + } + if (body.ok === true && typeof body.hash === 'string') { + addAttemptHash(storage, attempt, body.hash) + } +} + +/** The newest attempt at this same action inside the window, if there is one. */ +export function earlierAttempt( + storage: AttemptStorage | undefined, + asked: Pick, + now: number = Date.now() +): SignAttempt | undefined { + const matches = readAttempts(storage, now).filter( + (a) => + a.chain === asked.chain && + accountMismatch(a.account, asked.account) === undefined && + a.tokenIn.toUpperCase() === asked.tokenIn.toUpperCase() && + Number(a.amount) === Number(asked.amount) + ) + return matches[matches.length - 1] +} + +export interface AttemptNotice { + text: string + hash?: string + explorerUrl?: string +} + +function attemptExplorerUrl(chain: string, hash: string): string | undefined { + // Read back from storage, so it can only ever be a path on the explorer. + const safe = encodeURIComponent(hash) + if (chain === 'monad') return evmExplorerUrl('monad', safe) + return isCashoutChain(chain) ? depositExplorerUrl(chain, safe) : undefined +} + +/** What the card says about an earlier attempt. */ +export function attemptNotice(attempt: SignAttempt, now: number = Date.now()): AttemptNotice { + const minutes = Math.max(1, Math.floor((now - attempt.at) / 60_000)) + const text = `You started this ${minutes} minute${minutes === 1 ? '' : 's'} ago, check your wallet first.` + if (attempt.hash === undefined) return { text } + const explorerUrl = attemptExplorerUrl(attempt.chain, attempt.hash) + return { + text, + hash: attempt.hash, + ...(explorerUrl !== undefined ? { explorerUrl } : {}), + } +}