diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 92a45075..931b60bd 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -1,213 +1,216 @@ -NEXT_PUBLIC_APP_URL=http://localhost:3001 -NEXT_PUBLIC_API_URL=http://localhost:8080 -NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws - -# WalletConnect project id from https://cloud.reown.com -# Injected wallets (MetaMask, Rabby) work without this; WalletConnect and -# mobile wallets do not. -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= - -# Arc testnet RPC. Defaults to the public endpoint when unset. -NEXT_PUBLIC_ARC_RPC_URL=https://rpc.testnet.arc.io - -# --- Stellar network --- -# Which Stellar network the dapp executes on: testnet (the default) or mainnet. -# Anything else refuses to boot. Flipping to mainnet needs, at the least: -# - a funded mainnet sponsor in SPONSOR_SECRET_KEY: there is no friendbot, -# and an unfunded sponsor means users pay their own fees; -# - an RPC you trust in NEXT_PUBLIC_SOROBAN_RPC_URL (the public -# mainnet.sorobanrpc.com is fine to start) and, if you run one, a Horizon -# in NEXT_PUBLIC_STELLAR_HORIZON_URL — both override the active network only; -# - wallets on the public network: the app compares passphrases and asks -# for Stellar Mainnet; -# - knowing that Soroswap, its aggregator (needs SOROSWAP_API_KEY), Aquarius, -# the classic DEX, the network's liquidity pools, Blend and Soroban -# Domains are on mainnet, plus Etherfuse's CETES and USTRY bonds. DeFindex, -# Noether and every anchor stay testnet-only until their mainnet details -# are verified in the code; -# - no fiat off-ramp unless MoneyGram's production domain is set below. -# Every trade is capped in dollars on mainnet; see MAINNET_MAX_TRADE_USD. -NEXT_PUBLIC_STELLAR_NETWORK=testnet -# The Stellar networks this deployment serves, as a comma list that includes the -# default above, for example testnet,mainnet. Unset, only the default is served -# and nothing about the app changes. With more than one: -# - pages live under /stellar-mainnet and /stellar-testnet, and the old -# /stellar address redirects to the default network's; -# - the chain menu lists each network and switching loads the page afresh; -# - every network has its own fee sponsor (SPONSOR_SECRET_KEY_TESTNET and -# SPONSOR_SECRET_KEY_MAINNET; the plain SPONSOR_SECRET_KEY serves only the -# default network), its own spend ledger, standing rules, rate-limit -# buckets, saved wallet sessions and health report (/api/health?network=); -# - the access gate is paused: anyone can connect a wallet. Set ACCESS_GATE=on -# to bring back the email-code login; -# - the scheduled rule check runs each network on its own. -# Needs a funded mainnet sponsor and a database before mainnet is usable. -NEXT_PUBLIC_STELLAR_NETWORKS= -# Where the other Stellar network is deployed, for a deployment that serves only -# one (a mainnet deployment points at the testnet one, and the other way round). -# Adds it to the chain menu as a link. Ignored when NEXT_PUBLIC_STELLAR_NETWORKS -# lists both networks, and on mainnet, which never links to testnet. Leave empty -# and the menu does not mention the other network. -# -# Production serves mainnet only, so testnet is not reachable there: -# NEXT_PUBLIC_STELLAR_NETWORK=mainnet -# NEXT_PUBLIC_STELLAR_NETWORKS=mainnet -# Locally, list both to switch between them: testnet,mainnet -NEXT_PUBLIC_STELLAR_OTHER_NETWORK_URL= -# The most one trade may be worth on mainnet, in dollars, checked against the -# same prices the agents reason from. Ignored on testnet. Default 500. -MAINNET_MAX_TRADE_USD=500 -# MoneyGram's production home domain (stellar.moneygram.com), which turns the -# anchor on for mainnet. Needs MoneyGram's commercial agreement and a -# client_domain this deployment does not yet have; unset, mainnet has no -# off-ramp and says so. -MONEYGRAM_PRODUCTION_HOME_DOMAIN= - -# --- Access gate --- -# Postgres holds the waitlist, the sign-in codes, the rate limits and the usage log. -# On a serverless host use a hosted database's pooled connection string (Supabase: -# 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'))" -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 -# the gate, but with it on the fee sponsor pays only for a request that -# carries the session it issues; anything else submits paying its own fee. -ACCESS_GATE= - -# --- Rate limits --- -# Per client address, per family of route, as count/seconds. Counted in -# Postgres so the limit holds across instances; with no reachable database -# every request is allowed and one warning is logged. Families: build -# (quotes and transaction builds), submit, compete (intent parsing and the -# agent race), resolve (name lookups), standing (rules and inbox) and auth -# (otp, waitlist, admin, perps key session). These are the defaults from -# lib/server/rate-limit.ts; set one to change it. -# The address comes from the platform's own header: on Netlify -# x-nf-client-connection-ip, on Vercel (VERCEL is set there) x-vercel-forwarded-for -# then x-real-ip. The Netlify header is ignored on Vercel, which does not strip -# the client's copy. Behind any other proxy that only appends to -# x-forwarded-for, a client can choose its own bucket. -# RATE_LIMIT_BUILD=30/60 -# RATE_LIMIT_SUBMIT=10/60 -# RATE_LIMIT_COMPETE=10/60 -# RATE_LIMIT_RESOLVE=30/60 -# RATE_LIMIT_STANDING=30/60 -# RATE_LIMIT_AUTH=10/60 - -# Fee sponsorship. With a secret key here the server wraps every signed -# transaction in a fee-bump it pays for, so a user holding only USDC can still -# trade. On testnet the account funds itself from friendbot on first use. -# Users still need the 1 XLM base reserve to hold an account at all; this -# covers fees, not reserves. -# Needs DATABASE_URL: the budget below is kept there, and with no database -# configured the key pays for nothing (every submission goes out unsponsored -# with reason no_ledger, and GET /api/sponsor says so). -SPONSOR_SECRET_KEY= -# When several Stellar networks are served, a key per network. The plain key above -# serves the default network only, so a testnet key can never pay on mainnet. -SPONSOR_SECRET_KEY_TESTNET= -SPONSOR_SECRET_KEY_MAINNET= -# The most the sponsor pays in a day, in XLM, and how many sponsored -# submissions any one account gets in a day. Past either, the transaction -# goes out paying its own fee. 0 sponsors nothing without removing the key. -SPONSOR_DAILY_BUDGET_XLM=50 -SPONSOR_DAILY_PER_ACCOUNT=20 -# Min 16 chars. Guards /api/admin/waitlist and the /admin/waitlist page. -ADMIN_TOKEN= -# Optional in dev (codes log to console); required in production. -RESEND_API_KEY= -EMAIL_FROM= - -# The line-up. One entry per agent, in display order; the number of entries is -# the number of agents that race. An entry is a provider, or provider/model -# where model is a short name from the provider's table or a raw catalogue id. -# Leave unset to race every configured model: each provider's default plus all -# five curated OpenRouter models when that key is set (seven with the three -# hosted keys). Groq holds one agent: its free tier allows 8,000 tokens a -# minute and one prompt costs ~2,300. An entry whose key is missing is dropped -# with a warning, never swapped. -# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 - -DEEPSEEK_API_KEY= -DEEPSEEK_MODEL=deepseek-v4-flash -# Strict tool schemas live on the /beta endpoint. Set to false to use the -# standard one; responses are validated application-side either way. -DEEPSEEK_STRICT_TOOLS=true - -# Groq's free plan — no card required. https://console.groq.com/keys -# The binding limit is 8,000 tokens per minute, not the 30 requests: four -# agents racing at once can exceed it and come back 429. -GROQ_API_KEY= -GROQ_MODEL=qwen/qwen3.8-27b - -# OpenRouter's free tier — no card required. https://openrouter.ai/keys -# One key fronts every free model, and that is where the variety comes from: -# name a model after the provider in AGENT_BRAINS and each agent gets its own. -# Short names: ling-fin, nemotron-super, gemma-4, laguna-s, north-mini (see -# lib/agents/brains/providers.ts). Anything else is passed through as a raw -# catalogue id, e.g. openrouter/nvidia/nemotron-3-ultra-550b-a55b:free. -# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 -# Limits are per key across every free model: 20 requests a minute, and -# 50 a day until 10 credits have ever been bought (1,000 a day after). The -# default roster puts five agents here: ten races a day untopped, 200 after. -OPENROUTER_API_KEY= -OPENROUTER_MODEL=ling-fin - -# A model running locally via Ollama. Needs no key, and is unreachable from a -# deployed build — 127.0.0.1 is not the deploy host. Never selected unless -# named in AGENT_BRAINS, because a daemon that is not running looks exactly -# like one that is until every agent fails at once. -OLLAMA_MODEL=qwen3.5:latest -OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 -# Client flag: routes the competition through the agent endpoint. -NEXT_PUBLIC_USE_AI=false - -# Soroswap's aggregator API. Free key (starts with sk_) from -# https://api.soroswap.finance/register. Adds one more quote source that splits -# a swap across Soroswap, Aquarius and the classic DEX and returns the unsigned -# transaction, which is re-read against the quote before a wallet sees it. -# Unset, the aggregator is simply absent from the line-up. -SOROSWAP_API_KEY= - -# DeFindex yield vaults: a second lending venue beside Blend. Key from -# https://console.defindex.io (one key serves both networks; this app asks -# only for testnet). Unset, DeFindex is absent from what the agents are -# offered and nothing else changes. -DEFINDEX_API_KEY= - -# Noether perps gateway. The SDK default api.noether.exchange does not -# resolve; this Azure host was taken from the production bundle and answers. -# Testnet only; the gateway reports version 0.0.0-dev and is unaudited. -# NOETHER_API_URL=https://noether-api.proudmeadow-533cf0d8.germanywestcentral.azurecontainerapps.io -NOETHER_API_URL= - -# --- Standing rules --- -# Origin the fired-rule emails link into, e.g. https://intent.example (no trailing slash). -APP_URL=http://localhost:3001 -# Min 16 chars. Guards POST /api/standing/tick, which evaluates every armed rule. -# In development run `pnpm standing:tick`; in production call the route from a cron or scheduled function. -STANDING_TICK_SECRET= - -# --- Monitoring --- -# Sentry. With neither set, nothing is loaded and nothing is sent. The server -# DSN covers API routes and the tick; the public one covers the browser -# bundle. The build never uploads source maps, so no auth token is needed. -SENTRY_DSN= -NEXT_PUBLIC_SENTRY_DSN= -# Where operator alerts go. Unset, none are sent. The one alert today: the fee -# sponsor's balance has fallen below SPONSOR_ALERT_XLM — sent by the tick, once -# a day while it stays there. -ALERT_EMAIL= -SPONSOR_ALERT_XLM=20 - -# --- Cash-outs through Weave --- -# Server-only key for api.paywithweave.com. It can create real orders; never prefix it NEXT_PUBLIC. Unset, cash-outs are off. -WEAVE_SECRET_KEY= -# Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401. -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_RPC_URL= +NEXT_PUBLIC_APP_URL=http://localhost:3001 +NEXT_PUBLIC_API_URL=http://localhost:8080 +NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws + +# WalletConnect project id from https://cloud.reown.com +# Injected wallets (MetaMask, Rabby) work without this; WalletConnect and +# mobile wallets do not. +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= + +# Arc testnet RPC. Defaults to the public endpoint when unset. +NEXT_PUBLIC_ARC_RPC_URL=https://rpc.testnet.arc.io + +# --- Stellar network --- +# Which Stellar network the dapp executes on: testnet (the default) or mainnet. +# Anything else refuses to boot. Flipping to mainnet needs, at the least: +# - a funded mainnet sponsor in SPONSOR_SECRET_KEY: there is no friendbot, +# and an unfunded sponsor means users pay their own fees; +# - an RPC you trust in NEXT_PUBLIC_SOROBAN_RPC_URL (the public +# mainnet.sorobanrpc.com is fine to start) and, if you run one, a Horizon +# in NEXT_PUBLIC_STELLAR_HORIZON_URL — both override the active network only; +# - wallets on the public network: the app compares passphrases and asks +# for Stellar Mainnet; +# - knowing that Soroswap, its aggregator (needs SOROSWAP_API_KEY), Aquarius, +# the classic DEX, the network's liquidity pools, Blend and Soroban +# Domains are on mainnet, plus Etherfuse's CETES and USTRY bonds. DeFindex, +# Noether and every anchor stay testnet-only until their mainnet details +# are verified in the code; +# - no fiat off-ramp unless MoneyGram's production domain is set below. +NEXT_PUBLIC_STELLAR_NETWORK=testnet +# The Stellar networks this deployment serves, as a comma list that includes the +# default above, for example testnet,mainnet. Unset, only the default is served +# and nothing about the app changes. With more than one: +# - pages live under /stellar-mainnet and /stellar-testnet, and the old +# /stellar address redirects to the default network's; +# - the chain menu lists each network and switching loads the page afresh; +# - every network has its own fee sponsor (SPONSOR_SECRET_KEY_TESTNET and +# SPONSOR_SECRET_KEY_MAINNET; the plain SPONSOR_SECRET_KEY serves only the +# default network), its own spend ledger, standing rules, rate-limit +# buckets, saved wallet sessions and health report (/api/health?network=); +# - the access gate is paused: anyone can connect a wallet. Set ACCESS_GATE=on +# to bring back the email-code login; +# - the scheduled rule check runs each network on its own. +# Needs a funded mainnet sponsor and a database before mainnet is usable. +NEXT_PUBLIC_STELLAR_NETWORKS= +# Where the other Stellar network is deployed, for a deployment that serves only +# one (a mainnet deployment points at the testnet one, and the other way round). +# Adds it to the chain menu as a link. Ignored when NEXT_PUBLIC_STELLAR_NETWORKS +# lists both networks, and on mainnet, which never links to testnet. Leave empty +# and the menu does not mention the other network. +# +# Production serves mainnet only, so testnet is not reachable there: +# NEXT_PUBLIC_STELLAR_NETWORK=mainnet +# NEXT_PUBLIC_STELLAR_NETWORKS=mainnet +# Locally, list both to switch between them: testnet,mainnet +NEXT_PUBLIC_STELLAR_OTHER_NETWORK_URL= +# MoneyGram's production home domain (stellar.moneygram.com), which turns the +# anchor on for mainnet. Needs MoneyGram's commercial agreement and a +# client_domain this deployment does not yet have; unset, mainnet has no +# off-ramp and says so. +MONEYGRAM_PRODUCTION_HOME_DOMAIN= + +# --- Access gate --- +# Postgres holds the waitlist, the sign-in codes, the rate limits and the usage log. +# On a serverless host use a hosted database's pooled connection string (Supabase: +# the transaction pooler, port 6543); localhost only works on your own machine. +DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent +# Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Also signs the 1Click deposits issued on Solana, Sui, Avalanche and Monad. Without it that route +# isn't offered, which leaves Avalanche and Monad with no swaps at all. +AUTH_SECRET= +# on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only +# ACCESS_GATE=on turns it on, on any network. The API is outside +# the gate, but with it on the fee sponsor pays only for a request that +# carries the session it issues; anything else submits paying its own fee. +ACCESS_GATE= + +# --- Rate limits --- +# Per client address, per family of route, as count/seconds. Counted in +# Postgres so the limit holds across instances; with no reachable database +# every request is allowed and one warning is logged. Families: build +# (quotes and transaction builds), submit, compete (intent parsing and the +# agent race), resolve (name lookups), standing (rules and inbox) and auth +# (otp, waitlist, admin, perps key session). These are the defaults from +# lib/server/rate-limit.ts; set one to change it. +# The address comes from the platform's own header: on Netlify +# x-nf-client-connection-ip, on Vercel (VERCEL is set there) x-vercel-forwarded-for +# then x-real-ip. The Netlify header is ignored on Vercel, which does not strip +# the client's copy. Behind any other proxy that only appends to +# x-forwarded-for, a client can choose its own bucket. +# RATE_LIMIT_BUILD=30/60 +# RATE_LIMIT_READ=120/60 +# RATE_LIMIT_SUBMIT=10/60 +# RATE_LIMIT_COMPETE=10/60 +# RATE_LIMIT_RESOLVE=30/60 +# RATE_LIMIT_STANDING=30/60 +# RATE_LIMIT_AUTH=10/60 + +# Fee sponsorship. With a secret key here the server wraps every signed +# transaction in a fee-bump it pays for, so a user holding only USDC can still +# trade. On testnet the account funds itself from friendbot on first use. +# Users still need the 1 XLM base reserve to hold an account at all; this +# covers fees, not reserves. +# Needs DATABASE_URL: the budget below is kept there, and with no database +# configured the key pays for nothing (every submission goes out unsponsored +# with reason no_ledger, and GET /api/sponsor says so). +SPONSOR_SECRET_KEY= +# When several Stellar networks are served, a key per network. The plain key above +# serves the default network only, so a testnet key can never pay on mainnet. +SPONSOR_SECRET_KEY_TESTNET= +SPONSOR_SECRET_KEY_MAINNET= +# The most the sponsor pays in a day, in XLM, and how many sponsored +# submissions any one account gets in a day. Past either, the transaction +# goes out paying its own fee. 0 sponsors nothing without removing the key. +SPONSOR_DAILY_BUDGET_XLM=50 +SPONSOR_DAILY_PER_ACCOUNT=20 +# Min 16 chars. Guards /api/admin/waitlist and the /admin/waitlist page. +ADMIN_TOKEN= +# Optional in dev (codes log to console); required in production. +RESEND_API_KEY= +EMAIL_FROM= + +# The line-up. One entry per agent, in display order; the number of entries is +# the number of agents that race. An entry is a provider, or provider/model +# where model is a short name from the provider's table or a raw catalogue id. +# Leave unset to race every configured model: each provider's default plus all +# five curated OpenRouter models when that key is set (seven with the three +# hosted keys). Groq holds one agent: its free tier allows 8,000 tokens a +# minute and one prompt costs ~2,300. An entry whose key is missing is dropped +# with a warning, never swapped. +# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 + +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-v4-flash +# Strict tool schemas live on the /beta endpoint. Set to false to use the +# standard one; responses are validated application-side either way. +DEEPSEEK_STRICT_TOOLS=true + +# Groq's free plan — no card required. https://console.groq.com/keys +# The binding limit is 8,000 tokens per minute, not the 30 requests: four +# agents racing at once can exceed it and come back 429. +GROQ_API_KEY= +GROQ_MODEL=qwen/qwen3.8-27b + +# OpenRouter's free tier — no card required. https://openrouter.ai/keys +# One key fronts every free model, and that is where the variety comes from: +# name a model after the provider in AGENT_BRAINS and each agent gets its own. +# Short names: ling-fin, nemotron-super, gemma-4, laguna-s, north-mini (see +# lib/agents/brains/providers.ts). Anything else is passed through as a raw +# catalogue id, e.g. openrouter/nvidia/nemotron-3-ultra-550b-a55b:free. +# AGENT_BRAINS=deepseek,groq,openrouter/ling-fin,openrouter/nemotron-super,openrouter/gemma-4 +# Limits are per key across every free model: 20 requests a minute, and +# 50 a day until 10 credits have ever been bought (1,000 a day after). The +# default roster puts five agents here: ten races a day untopped, 200 after. +OPENROUTER_API_KEY= +OPENROUTER_MODEL=ling-fin + +# A model running locally via Ollama. Needs no key, and is unreachable from a +# deployed build — 127.0.0.1 is not the deploy host. Never selected unless +# named in AGENT_BRAINS, because a daemon that is not running looks exactly +# like one that is until every agent fails at once. +OLLAMA_MODEL=qwen3.5:latest +OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 +# Client flag: routes the competition through the agent endpoint. +NEXT_PUBLIC_USE_AI=false + +# Soroswap's aggregator API. Free key (starts with sk_) from +# https://api.soroswap.finance/register. Adds one more quote source that splits +# a swap across Soroswap, Aquarius and the classic DEX and returns the unsigned +# transaction, which is re-read against the quote before a wallet sees it. +# Unset, the aggregator is simply absent from the line-up. +SOROSWAP_API_KEY= + +# DeFindex yield vaults: a second lending venue beside Blend. Key from +# https://console.defindex.io (one key serves both networks; this app asks +# only for testnet). Unset, DeFindex is absent from what the agents are +# offered and nothing else changes. +DEFINDEX_API_KEY= + +# Noether perps gateway. The SDK default api.noether.exchange does not +# resolve; this Azure host was taken from the production bundle and answers. +# Testnet only; the gateway reports version 0.0.0-dev. Noether's mainnet market +# was deployed on 2026-10-03 and read as paused with no funds on 2026-11-05: its +# own docs say trading opens after an independent audit. +# NOETHER_API_URL=https://noether-api.proudmeadow-533cf0d8.germanywestcentral.azurecontainerapps.io +NOETHER_API_URL= + +# --- Standing rules --- +# Origin the fired-rule emails link into, e.g. https://intent.example (no trailing slash). +APP_URL=http://localhost:3001 +# Min 16 chars. Guards POST /api/standing/tick, which evaluates every armed rule. +# In development run `pnpm standing:tick`; in production call the route from a cron or scheduled function. +STANDING_TICK_SECRET= + +# --- Monitoring --- +# Sentry. With neither set, nothing is loaded and nothing is sent. The server +# DSN covers API routes and the tick; the public one covers the browser +# bundle. The build never uploads source maps, so no auth token is needed. +SENTRY_DSN= +NEXT_PUBLIC_SENTRY_DSN= +# Where operator alerts go. Unset, none are sent. The one alert today: the fee +# sponsor's balance has fallen below SPONSOR_ALERT_XLM — sent by the tick, once +# a day while it stays there. +ALERT_EMAIL= +SPONSOR_ALERT_XLM=20 + +# --- Cash-outs through Weave --- +# Server-only key for api.paywithweave.com. It can create real orders; never prefix it NEXT_PUBLIC. Unset, cash-outs are off. +WEAVE_SECRET_KEY= +# Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401. +WEAVE_WEBHOOK_SECRET= +# Comma list from NGN,KES,UGX,TZS. Unset means NGN only. No real payout has been tested through this app for any of them. +WEAVE_CASHOUT_CURRENCIES= +# 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/[chain]/intents/new/page.tsx b/apps/dapp/app/[chain]/intents/new/page.tsx index f891cb85..ff230309 100644 --- a/apps/dapp/app/[chain]/intents/new/page.tsx +++ b/apps/dapp/app/[chain]/intents/new/page.tsx @@ -2,26 +2,14 @@ import { parseChainSegment } from '@intent/config' import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { IntentChat } from '../../../../components/intents/intent-chat' -import { intentIntro } from '../../../../lib/intent-intro-copy' - export const metadata: Metadata = { title: 'New intent' } +/** The chat is drawn by `ChatHost` in the chain layout, under this page's own heading. */ export default async function NewIntentPage({ params, }: { params: Promise<{ chain: string }> -}): Promise { - const route = parseChainSegment((await params).chain) - if (route === undefined) notFound() - - return ( -
-
-

Submit an intent

-

{intentIntro(route.slug)}

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

Intents

-

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

-
- - -
- ) +export default function IntentsPage(): null { + return null } diff --git a/apps/dapp/app/[chain]/layout.tsx b/apps/dapp/app/[chain]/layout.tsx index 3dd77a70..fa15df92 100644 --- a/apps/dapp/app/[chain]/layout.tsx +++ b/apps/dapp/app/[chain]/layout.tsx @@ -3,8 +3,9 @@ import { cookies } from 'next/headers' import { notFound } from 'next/navigation' import { WalletGate } from '../../components/connect/wallet-gate' +import { ChatHost } from '../../components/intents/chat-host' import { AppShell } from '../../components/layout/app-shell' -import { tradeCapUsd } from '../../lib/server/trade-cap' +import { BetaChainNotice } from '../../components/layout/beta-chain-notice' import { ChainProvider } from '../../providers/chain-provider' /** @@ -28,15 +29,6 @@ export default async function ChainLayout({ // showing a different chain than the URL names would be worse than an error. if (parseChainSegment(chain) === undefined) notFound() - // A misconfigured cap must not take the whole frame down with it; the - // routes that enforce it report the problem where it matters. - let cap: number | undefined - try { - cap = tradeCapUsd() - } catch { - cap = undefined - } - // For the browser tests, which have no wallet. Needs the server's own setting // as well as the cookie, so a visitor cannot switch the gate off in production. const bypass = @@ -46,7 +38,10 @@ export default async function ChainLayout({ return ( - {children} + + + {children} + ) diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 57bf8348..6971d8ed 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -2,7 +2,12 @@ import { activeNetwork } from '@intent/config' import { NextResponse } from 'next/server' import { randomUUID } from 'node:crypto' -import type { AgentProposalResult } from '../../../../lib/agents/brain' +import type { AgentProposalResult, MarketContext } from '../../../../lib/agents/brain' +import { + intentFromChainAction, + quoteChainRoutes, + type ChainRouteQuote, +} from '../../../../lib/agents/chain-routes' import type { CompetitionFrame } from '../../../../lib/agents/events' import { encodeFrame } from '../../../../lib/agents/events' import { buildMarketContextAsync, quoteRoutes } from '../../../../lib/agents/market-context' @@ -11,6 +16,22 @@ 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 { runTalk } from '../../../../lib/agents/talk' +import { applyFinalChoice } from '../../../../lib/agents/talk-final' +import { + assertValidAmount, + assertKnownTokens, + assertNativeReserve, +} from '../../../../lib/chains/prepare' +import { limitPair } from '../../../../lib/chains/limit' +import { restOption, restRoute } from '../../../../lib/agents/rest-option' +import { chainModule } from '../../../../lib/chains/registry' +import { + ChainActionRefused, + type ChainActionKind, + type ChainActionRequest, + type ChainModule, +} from '../../../../lib/chains/types' import { parseCompoundIntent } from '../../../../lib/parse-compound' import { isLimitType } from '../../../../lib/intent-kind' import { buildParsedIntent, parseIntent, type ReadingForIntent } from '../../../../lib/parse-intent' @@ -20,6 +41,11 @@ import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { fetchOrderBookTop } from '../../../../lib/swap/limit-price' import type { OrderBookTop } from '../../../../lib/swap/limit-price' import type { SwapQuote } from '../../../../lib/swap/quote' +import { + applyChainMeasure, + isUnmeasuredSwap, + settleChainPlan, +} from '../../../../lib/agents/settle-chain-plan' import { resolveExecutionPlan } from '../../../../lib/agents/tool-schema' import type { ParsedIntent } from '../../../../lib/parse-intent' import { logRace, type RaceInput } from '../../../../lib/server/analytics' @@ -57,7 +83,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 +91,112 @@ const WINDOW_SECONDS = 30 * which the agents were offered Uniswap. Now a missing or unsupported chain is * an answer the user reads, not a race that quietly runs on the wrong facts. */ -const EXECUTING_CHAINS: Record = { - stellar: 'Stellar', +function canExecuteOn(chain: string): boolean { + return chain === 'stellar' || chainModule(chain) !== undefined +} + +const CHAIN_ACTION_KINDS: readonly ChainActionKind[] = ['swap', 'supply', 'stake'] + +/** The longest account a chain uses is a 64-character NEAR implicit account; Sui and EVM are shorter. */ +const MAX_ACCOUNT_CHARS = 128 + +/** + * Reads the structured action from the body, or nothing. Only the shape is + * checked here; whether the tokens are real is checked against the chain. + */ +function chainActionFromBody(raw: unknown): ChainActionRequest | undefined { + if (typeof raw !== 'object' || raw === null) return undefined + const { kind, amount, tokenIn, tokenOut, venue } = raw as Record + if (typeof kind !== 'string' || !CHAIN_ACTION_KINDS.includes(kind as ChainActionKind)) { + return undefined + } + if (typeof amount !== 'string' || typeof tokenIn !== 'string') return undefined + return { + kind: kind as ChainActionKind, + amount: amount.slice(0, 40), + tokenIn: tokenIn.slice(0, 40), + ...(typeof tokenOut === 'string' ? { tokenOut: tokenOut.slice(0, 40) } : {}), + ...(typeof venue === 'string' ? { venue: venue.slice(0, 40) } : {}), + } +} + +/** + * What the agents are given for an action on a chain that is not Stellar: the + * chain's own venues and real quotes, and none of Stellar's prices, assets or + * order book. Returns the sentence to show instead when there is nothing to race. + * A `refusal` is the action itself being refused, which the user must read as + * written; a plain string is a race that could not run, where the direct route + * is still worth trying. + */ +async function chainRaceFacts( + chain: string, + chainApi: ChainModule, + action: ChainActionRequest, + account: string | undefined, + text: string +): Promise<{ intent: ParsedIntent; market: MarketContext } | string | { refusal: string }> { + // No venue to quote, and none for the direct route to build on, so nothing is worth trying. + if (!chainApi.venues.some((v) => v.kinds.includes(action.kind))) { + return { + refusal: `Nothing here can ${action.kind} on this chain yet, so nothing was signed or sent.`, + } + } + try { + assertKnownTokens(chainApi, action) + assertValidAmount(action) + } catch (e) { + if (e instanceof ChainActionRefused) + return { refusal: `${e.message} Nothing was signed or sent.` } + throw e + } + if (account === undefined) { + return 'No wallet account came with this request, so nothing could be priced. Nothing was signed or sent.' + } + try { + await assertNativeReserve(chainApi, action, account) + } catch (e) { + // Worded in full already, ending as every refusal here does. + if (e instanceof ChainActionRefused) return { refusal: e.message } + throw e + } + + const routes = await quoteChainRoutes(chainApi, action, account) + if (routes.length === 0) { + return 'No route on this chain could be priced right now, so the agents had nothing to judge. Nothing was signed or sent.' + } + + const tokenIn = chainApi.tokens.find( + (t) => t.symbol.toUpperCase() === action.tokenIn.toUpperCase() + ) + const price = + tokenIn === undefined ? undefined : await chainApi.priceUsd(tokenIn).catch(() => undefined) + + // The chain's market already leaves out Stellar's assets, prices, lending + // rates and anchors; see `buildMarketContext`. + const base = await buildMarketContextAsync(chain) + const pair = action.kind === 'swap' ? limitPair(action.tokenIn, action.tokenOut ?? '') : undefined + const baseToken = chainApi.tokens.find((t) => t.symbol.toUpperCase() === pair?.base) + const marketUsd = + pair === undefined || baseToken === undefined + ? undefined + : pair.selling + ? price + : await chainApi.priceUsd(baseToken).catch(() => undefined) + const rest = marketUsd === undefined ? undefined : restOption(chainApi, action, marketUsd) + const market: MarketContext = { + ...base, + ...(rest !== undefined ? { rest } : {}), + prices: price !== undefined && tokenIn !== undefined ? { [tokenIn.symbol]: price } : {}, + venues: chainApi.venues + .filter((v) => routes.some((r) => r.id === `${v.id}-1`)) + .map((v) => ({ + id: v.id, + name: v.name, + category: action.kind === 'swap' ? 'dex' : action.kind === 'supply' ? 'lending' : 'staking', + })), + routes, + } + return { intent: intentFromChainAction(action, text, price), market } } /** @@ -120,10 +250,11 @@ function settlePlan( */ function measured( proposal: AgentProposalResult, - quote: SwapQuote | undefined, + quote: SwapQuote | ChainRouteQuote | undefined, prices: Record ): AgentProposalResult { if (quote === undefined) return proposal + if ('kind' in quote && quote.kind === 'chain') return applyChainMeasure(proposal, quote) const m = measureRoute(quote, prices) if (m === undefined) return proposal return { @@ -134,7 +265,10 @@ function measured( } /** One SSE stream carrying a single error frame, then closing. */ -function errorStream(code: 'agents_offline' | 'chain_unsupported', message: string): Response { +function errorStream( + code: 'agents_offline' | 'chain_unsupported' | 'action_refused', + message: string +): Response { const encoder = new TextEncoder() const stream = new ReadableStream({ start(controller) { @@ -167,7 +301,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 +314,10 @@ export async function POST(request: Request): Promise { let chain: string let context: string[] let reading: ReadingForIntent | undefined + let chainAction: ChainActionRequest | undefined + // Asked for by the client, because a tab from before talk shipped knows nothing + // of a held plan: it would offer Review on one and sign the winner's trade. + let clientTalks: boolean try { const body = (await request.json()) as { @@ -187,13 +325,19 @@ export async function POST(request: Request): Promise { chain?: unknown context?: unknown reading?: unknown + chainAction?: unknown + talk?: 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) + clientTalks = body.talk === true + // 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 +355,17 @@ export async function POST(request: Request): Promise { 'No chain was named for this intent. Reload the app and try again.' ) } - if (EXECUTING_CHAINS[chain] === undefined) { + if (!canExecuteOn(chain)) { + return errorStream( + 'chain_unsupported', + 'Agents can execute only on Stellar, Solana, Sui, NEAR, Avalanche and Monad right now. ' + + 'Switch the chain to run a competition.' + ) + } + if (chain !== 'stellar' && chainAction === undefined) { return errorStream( 'chain_unsupported', - `Agents can execute only on ${Object.values(EXECUTING_CHAINS).join(', ')} right now. ` + - `Switch the chain to run a competition.` + 'This request did not say what to do on this chain, so no race was run. Nothing was signed or sent.' ) } @@ -223,7 +373,10 @@ export async function POST(request: Request): Promise { if (full === undefined) { return errorStream( 'agents_offline', - 'The agents are not online right now, so nothing was proposed. Nothing can be executed until they are.' + // Off Stellar the chat builds the direct route next, so something can still be signed. + chain === 'stellar' + ? 'The agents are not online right now, so nothing was proposed. Nothing can be executed until they are.' + : 'The agents are not online right now, so nothing was proposed.' ) } const roster = lineUp(full) @@ -236,45 +389,60 @@ export async function POST(request: Request): Promise { ) } + // Off Stellar the facts come from the chain's own venues and quotes, and a + // refusal or an unpriceable action ends here, before any agent is called. + const chainApi = chain === 'stellar' ? undefined : chainModule(chain) + const chainFacts = + chainApi !== undefined && chainAction !== undefined + ? await chainRaceFacts(chain, chainApi, chainAction, account, text) + : undefined + if (typeof chainFacts === 'string') return errorStream('chain_unsupported', chainFacts) + if (chainFacts !== undefined && 'refusal' in chainFacts) { + return errorStream('action_refused', chainFacts.refusal) + } + const competitionId = randomUUID() // Taken here, in the request, so the race is recorded for the network it ran on. const network = activeNetwork() const startedAt = new Date() // Prices are fetched first so the parser can size "$30 of XLM" against the // real market rather than an indicative table. - const market = await buildMarketContextAsync(chain) + const market = chainFacts?.market ?? (await buildMarketContextAsync(chain)) // A structured reading, when the chat has one, is what the agents are given. // Re-reading the words would lose a paraphrase that no keyword recognises. const intent = - reading !== undefined + chainFacts?.intent ?? + (reading !== undefined ? buildParsedIntent(reading, market.prices) - : parseIntent(text, market.prices) + : parseIntent(text, market.prices)) // Priced before the agents run, so they choose between real routes rather // than describing hypothetical ones. Failing to quote is not fatal: the // competition proceeds without executable routes and says so. - try { - const routes = await quoteRoutes( - chain, - intent.input.tokenIn, - intent.input.tokenOut, - toBaseUnits(intent.input.amountIn), - undefined, - // A limit order states a price it will not trade through, expressed as - // a minimum output so the quoter can decline. Keyed on `limitPriceUsd` - // rather than `targetPriceUsd`: the latter falls back to spot when the - // user named no price, and a limit derived from the market is not one. - isLimitType(intent.input.type) && intent.limitPriceUsd !== undefined - ? { - minReceive: toBaseUnits( - (Number(intent.input.amountIn) * intent.limitPriceUsd).toFixed(7) - ), - } - : {} - ) - if (routes.length > 0) market.routes = routes - } catch { - // Leaving routes unset is the honest outcome; agents reason without them. + if (chainFacts === undefined) { + try { + const routes = await quoteRoutes( + chain, + intent.input.tokenIn, + intent.input.tokenOut, + toBaseUnits(intent.input.amountIn), + undefined, + // A limit order states a price it will not trade through, expressed as + // a minimum output so the quoter can decline. Keyed on `limitPriceUsd` + // rather than `targetPriceUsd`: the latter falls back to spot when the + // user named no price, and a limit derived from the market is not one. + isLimitType(intent.input.type) && intent.limitPriceUsd !== undefined + ? { + minReceive: toBaseUnits( + (Number(intent.input.amountIn) * intent.limitPriceUsd).toFixed(7) + ), + } + : {} + ) + if (routes.length > 0) market.routes = routes + } catch { + // Leaving routes unset is the honest outcome; agents reason without them. + } } // One read for the whole competition: every agent's resting price is checked @@ -282,7 +450,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 { @@ -292,14 +460,27 @@ export async function POST(request: Request): Promise { } } + let closed = false + // Fires when the person has gone, such as sending a note that restarts the race. + const personLeft = new AbortController() + const stream = new ReadableStream({ + cancel() { + closed = true + personLeft.abort() + }, async start(controller) { const encoder = new TextEncoder() - let closed = false const send = (frame: CompetitionFrame): void => { if (closed) return - controller.enqueue(encoder.encode(encodeFrame(frame))) + try { + controller.enqueue(encoder.encode(encodeFrame(frame))) + } catch { + // The stream ended underneath us, which is the person leaving by another name. + closed = true + personLeft.abort() + } } // What each agent did, for the analytics log. Only outcomes and timings: @@ -312,7 +493,9 @@ export async function POST(request: Request): Promise { ): Promise => logRace({ id: competitionId, - network, + // Other chains only run on mainnet, and their submits are logged there too. + network: chain === 'stellar' ? network : 'mainnet', + chain, startedAt, durationMs: Date.now() - startedAt.getTime(), intent: { @@ -334,85 +517,194 @@ 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 - // agent delays only its own card. - const settled = await Promise.all( - roster.map(async (agent, seat) => { - const began = Date.now() - const controllerForAgent = new AbortController() - const timer = setTimeout(() => controllerForAgent.abort(), AGENT_TIMEOUT_MS) - - try { - const outcome = await agent.brain.propose({ - intent, - agent: agent.key, - seat, - market, - chain, - ...(context.length > 0 ? { context } : {}), - signal: controllerForAgent.signal, - }) + // Agents talk once at least two of them can. A brain that can't keeps its + // first take, and a race where fewer than two can runs as it always has. + const talking = clientTalks && roster.filter((a) => a.brain.talk !== undefined).length >= 2 + const talkStartedAt = Date.now() + const rawFirstTakes = new Map() + const firstTakeAborts: AbortController[] = [] + + // The checked plan for what this request is, whichever turn produced it. + const settleAndMeasure = (proposal: AgentProposalResult): AgentProposalResult => { + const own = (market.routes ?? []).find((r) => r.id === proposal.routeId) + return measured( + chainFacts === undefined + ? settlePlan(proposal, intent, book) + : settleChainPlan(proposal, own !== undefined ? market.rest : undefined), + own?.quote as SwapQuote | ChainRouteQuote | undefined, + market.prices + ) + } - if (!outcome.ok) { - if (outcome.error === 'rate_limited') { - noteRateLimited(agent.key, outcome.retryAfterMs) + // A plan that rests an order signs the order, not the swap it was measured against. + const sendProposal = (proposal: AgentProposalResult): void => { + const own = (market.routes ?? []).find((r) => r.id === proposal.routeId) + send({ + type: 'competition:proposal', + competitionId, + proposal, + ...(own !== undefined + ? { + route: + proposal.executionMode === 'rest' && + market.rest !== undefined && + proposal.restPriceUsd !== undefined + ? restRoute(own.quote as ChainRouteQuote, market.rest, proposal.restPriceUsd) + : own.quote, } - attempts.push({ - agent: agent.key, - model: agent.model, - ok: false, - latencyMs: Date.now() - began, - error: outcome.error, - }) - // Said, and left empty. No placeholder: an agent that did not - // answer has no proposal, and showing one would be inventing it. - send({ - type: 'competition:failed', - competitionId, - agent: agent.key, - error: outcome.error, - }) - return undefined - } - - // This agent's own route, so executing it signs what it proposed - // rather than what the winner proposed. - const own = (market.routes ?? []).find((r) => r.id === outcome.proposal.routeId) - - const proposal = measured( - settlePlan(outcome.proposal, intent, book), - own?.quote as SwapQuote | undefined, - market.prices - ) + : {}), + }) + } + // Agents run concurrently and are emitted as they finish, so one slow + // agent delays only its own card. + const takes = roster.map(async (agent, seat) => { + const began = Date.now() + const controllerForAgent = new AbortController() + firstTakeAborts.push(controllerForAgent) + personLeft.signal.addEventListener('abort', () => controllerForAgent.abort(), { + once: true, + }) + const timer = setTimeout(() => controllerForAgent.abort(), AGENT_TIMEOUT_MS) + + try { + const outcome = await agent.brain.propose({ + intent, + agent: agent.key, + seat, + market, + chain, + ...(context.length > 0 ? { context } : {}), + signal: controllerForAgent.signal, + }) + + if (!outcome.ok) { + if (outcome.error === 'rate_limited') { + noteRateLimited(agent.key, outcome.retryAfterMs) + } attempts.push({ agent: agent.key, model: agent.model, - ok: true, + ok: false, latencyMs: Date.now() - began, - routeId: proposal.routeId, - executionMode: proposal.executionMode, - // Passed for every agent; raceRecordFrom is the one place that - // keeps it only for the row that actually won. - reasoning: proposal.reasoning, + error: outcome.error, }) + // Said, and left empty. No placeholder: an agent that did not + // answer has no proposal, and showing one would be inventing it. + send({ + type: 'competition:failed', + competitionId, + agent: agent.key, + error: outcome.error, + }) + return undefined + } + // This agent's own route, so executing it signs what it proposed + // rather than what the winner proposed. + const own = (market.routes ?? []).find((r) => r.id === outcome.proposal.routeId) + + const proposal = settleAndMeasure(outcome.proposal) + rawFirstTakes.set(agent.key, outcome.proposal) + + attempts.push({ + agent: agent.key, + model: agent.model, + ok: true, + latencyMs: Date.now() - began, + routeId: proposal.routeId, + executionMode: proposal.executionMode, + // Passed for every agent; raceRecordFrom is the one place that + // keeps it only for the row that actually won. + reasoning: proposal.reasoning, + }) + + // A first take in a race that talks may still change, so it carries + // no route: there is nothing to review until final plans are in. + if (talking) { send({ - type: 'competition:proposal', + type: 'competition:take', competitionId, proposal, - ...(own !== undefined ? { route: own.quote } : {}), + ...(own !== undefined ? { source: own.source } : {}), }) - return proposal - } finally { - clearTimeout(timer) + } else { + sendProposal(proposal) } - }) - ) + return proposal + } finally { + clearTimeout(timer) + } + }) - const proposals = settled.filter((p): p is AgentProposalResult => p !== undefined) + let proposals: AgentProposalResult[] + if (talking) { + const firstTakes = new Map() + takes.forEach((take, i) => { + const key = roster[i]?.key + void take.then((plan) => { + if (plan !== undefined && key !== undefined) firstTakes.set(key, plan) + }) + }) + const { finals } = await runTalk({ + seats: roster.map(({ key, name, brain }) => ({ + key, + name, + talk: brain.talk?.bind(brain), + })), + takes, + facts: { + chain, + // Whatever the clarifier confirmed, since every agent is held to it. + intent: context.length > 0 ? `${text} (${context.join('; ')})` : text, + routes: market.routes ?? [], + }, + startedAt: talkStartedAt, + emit: (line) => send({ type: 'competition:talk', competitionId, line }), + applyFinal: (agentKey, choice) => { + const first = firstTakes.get(agentKey) + const rawFirst = rawFirstTakes.get(agentKey) + if (first === undefined || rawFirst === undefined) return undefined + const seat = roster.findIndex((a) => a.key === agentKey) + return applyFinalChoice({ + first, + rawFirst, + choice, + request: { + intent, + agent: agentKey, + seat, + market, + chain, + ...(context.length > 0 ? { context } : {}), + }, + settle: settleAndMeasure, + }) + }, + closeLateTakes: () => { + for (const abort of firstTakeAborts) abort.abort() + }, + onRateLimited: (agentKey, retryAfterMs) => noteRateLimited(agentKey, retryAfterMs), + signal: personLeft.signal, + }) + proposals = finals + for (const final of finals) { + // The record shows what the agent ended on, not where it started. + const attempt = attempts.find((a) => a.agent === final.agent && a.ok) + if (attempt !== undefined && attempt.ok) { + attempt.routeId = final.routeId + attempt.executionMode = final.executionMode + attempt.reasoning = final.reasoning + } + sendProposal(final) + } + } else { + const settled = await Promise.all(takes) + proposals = settled.filter((p): p is AgentProposalResult => p !== undefined) + } if (proposals.length === 0) { send({ @@ -422,18 +714,37 @@ export async function POST(request: Request): Promise { 'None of the agents answered this time. Nothing was proposed. Try again in a moment.', }) await record(null, null, {}) - closed = true - controller.close() + if (!closed) { + closed = true + controller.close() + } return } // What the user actually asked for, so a follow-on nobody requested // can be told apart from one they did. The same parser the sequence // flow uses, rather than a second opinion about the same sentence. - const compound = parseCompoundIntent(text, market.prices) + const compound = + chainFacts === undefined ? parseCompoundIntent(text, market.prices) : undefined const followOn = compound?.followOn.kind const anchorLimits = (market.offramps ?? []).find((o) => o.asset === 'USDC') + // A supply or a stake is ranked on the rate each route shows. + const apyEntries = (market.routes ?? []).flatMap((r) => { + const q = r.quote as Partial | undefined + return q?.kind === 'chain' && q.apyPercent !== undefined + ? [[r.id, q.apyPercent] as const] + : [] + }) + const apyByRoute = apyEntries.length > 0 ? Object.fromEntries(apyEntries) : undefined + + const unmeasuredRoutes = (market.routes ?? []) + .filter((r) => { + const q = r.quote as Partial | undefined + return q?.kind === 'chain' && isUnmeasuredSwap(q as ChainRouteQuote) + }) + .map((r) => r.id) + const plan: PlanContext = { escrowUsd: intent.escrowUsd, referencePriceUsd: intent.referencePriceUsd, @@ -449,9 +760,17 @@ export async function POST(request: Request): Promise { ...(book?.ask !== undefined ? { recentHighUsd: book.ask } : {}), ...(anchorLimits?.maxAmount !== undefined ? { offrampMaxUsd: anchorLimits.maxAmount } : {}), ...(anchorLimits?.minAmount !== undefined ? { offrampMinUsd: anchorLimits.minAmount } : {}), + ...(apyByRoute !== undefined ? { apyByRoute } : {}), + ...(unmeasuredRoutes.length > 0 ? { unmeasuredRoutes } : {}), } - const scored = scoreProposals(proposals, { competitionId, plan }) + const scored = scoreProposals(proposals, { + competitionId, + plan, + ...(chainFacts !== undefined ? { restsBelowFills: true } : {}), + }) + // An agent that held off has no plan to score. + const heldOff = new Set(proposals.filter((p) => p.held === true).map((p) => p.agent)) const winner = pickWinner(scored) let unanimous: boolean | null = null @@ -469,11 +788,15 @@ export async function POST(request: Request): Promise { type: 'competition:winner', competitionId, winner, - scores: Object.fromEntries(scored.map((s) => [s.agent, s.score])), + scores: Object.fromEntries( + scored.filter((s) => !heldOff.has(s.agent)).map((s) => [s.agent, s.score]) + ), // Only the agents that actually lost points, so the common case // sends nothing extra. penalties: Object.fromEntries( - scored.filter((s) => s.penalties.length > 0).map((s) => [s.agent, s.penalties]) + scored + .filter((s) => s.penalties.length > 0 && !heldOff.has(s.agent)) + .map((s) => [s.agent, s.penalties]) ), unanimous, ...(chosen !== undefined ? { route: chosen.quote } : {}), @@ -483,10 +806,14 @@ export async function POST(request: Request): Promise { await record( winner, winner !== null ? unanimous : null, - Object.fromEntries(scored.map((s) => [s.agent, s.score])) + Object.fromEntries( + scored.filter((s) => !heldOff.has(s.agent)).map((s) => [s.agent, s.score]) + ) ) - closed = true - controller.close() + if (!closed) { + closed = true + controller.close() + } }, }) diff --git a/apps/dapp/app/api/agents/history/route.ts b/apps/dapp/app/api/agents/history/route.ts index e4aaf623..532d825a 100644 --- a/apps/dapp/app/api/agents/history/route.ts +++ b/apps/dapp/app/api/agents/history/route.ts @@ -2,6 +2,7 @@ import { activeNetwork } from '@intent/config' import { StrKey } from '@stellar/stellar-sdk' import { NextResponse } from 'next/server' +import { isWalletAddress } from '../../../../lib/wallet/address' import { databaseConfigured } from '../../../../lib/server/db' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { getAnalyticsRepo } from '../../../../lib/server/analytics' @@ -13,6 +14,15 @@ export const dynamic = 'force-dynamic' const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 +const STELLAR_SHAPE = /^G[A-Z2-7]{55}$/ + +/** A Stellar address is checked by its checksum; the other chains' by the shape the app accepts. */ +function validAccount(account: string): boolean { + return STELLAR_SHAPE.test(account) + ? StrKey.isValidEd25519PublicKey(account) + : isWalletAddress(account) +} + /** * A wallet's own agent races, each with the winning agent's reasoning and * the transaction this wallet actually submitted for it, when there was one. @@ -34,7 +44,7 @@ export async function GET(request: Request): Promise { const url = new URL(request.url) const account = url.searchParams.get('account') - if (account === null || !StrKey.isValidEd25519PublicKey(account)) { + if (account === null || !validAccount(account)) { return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) } @@ -49,7 +59,8 @@ export async function GET(request: Request): Promise { try { const repo = await getAnalyticsRepo() const result = await repo.historyForAccount({ - network: activeNetwork(), + // Other chains' actions are logged as mainnet, whatever Stellar network the page is on. + network: STELLAR_SHAPE.test(account) ? activeNetwork() : 'mainnet', account, page, pageSize, diff --git a/apps/dapp/app/api/cashout/orders/route.ts b/apps/dapp/app/api/cashout/orders/route.ts index 6692fe8d..44c28425 100644 --- a/apps/dapp/app/api/cashout/orders/route.ts +++ b/apps/dapp/app/api/cashout/orders/route.ts @@ -6,7 +6,6 @@ import { databaseConfigured } from '../../../../lib/server/db' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' import { requesterOf } from '../../../../lib/server/requester' -import { assertWithinCap, TradeCapExceeded, tradeCapUsd } from '../../../../lib/server/trade-cap' import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/client' import { isCurrencyAllowed } from '../../../../lib/weave/corridors' import { depositChain } from '../../../../lib/weave/deposit' @@ -22,7 +21,7 @@ import { toPublic, } from '../../../../lib/weave/orders' import { nextStatus } from '../../../../lib/weave/status' -import { isCashoutChain } from '../../../../lib/weave/types' +import { closedCashoutReply, isCashoutChain } from '../../../../lib/weave/types' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -56,23 +55,6 @@ function refusalFor(path: (string | number)[]): string { } } -/** - * The cap always reads as mainnet, because a cash-out always is. Its own wording is - * for whoever runs the deployment, so a person gets this instead. - */ -function overCap(usd: number): Response | undefined { - try { - assertWithinCap(usd, process.env, 'mainnet') - return undefined - } catch (e) { - if (!(e instanceof TradeCapExceeded)) throw e - return refusal( - 'over_limit', - `This app caps each cash-out at $${tradeCapUsd(process.env, 'mainnet')}, and this one is about $${usd.toFixed(2)}. Try a smaller amount.` - ) - } -} - /** This requester's cash-outs, newest first. Empty rather than an error when there is no record to read. */ export async function GET(request: Request): Promise { const who = requesterOf(request) @@ -112,6 +94,9 @@ export async function POST(request: Request): Promise { if (!isCashoutChain(chain)) { return refusal('unknown_chain', 'Cash-outs are not available on this chain.') } + // The chat refuses this before any form opens. A page left open from before it did can still post. + const closed = closedCashoutReply(chain) + if (closed !== undefined) return refusal('chain_closed', closed, 422) const onThisNetwork = chainRefusal(chain) if (onThisNetwork !== undefined) return onThisNetwork @@ -172,9 +157,6 @@ export async function POST(request: Request): Promise { ) } - const capped = overCap(Number(amount)) - if (capped !== undefined) return capped - const weave = createWeaveClient() const fail = (code: string, message: string): Response => refusal(code, message, code === 'rejected' ? 422 : 503) diff --git a/apps/dapp/app/api/chain/[chain]/balances/route.ts b/apps/dapp/app/api/chain/[chain]/balances/route.ts new file mode 100644 index 00000000..2ebc8c11 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/balances/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server' + +import { chainModule } from '../../../../../lib/chains/registry' +import { BALANCE_DEADLINE_MS, ChainActionRefused } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 20 + +/** Every token the account holds on this chain, read when asked and never kept. */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const account = new URL(request.url).searchParams.get('account') ?? '' + try { + const balances = await chainApi.balances(account, AbortSignal.timeout(BALANCE_DEADLINE_MS)) + return NextResponse.json(balances, { headers: { 'cache-control': 'no-store' } }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + reportError('chain/balances', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'balances_unavailable' }, { status: 503 }) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/follow-on/route.ts b/apps/dapp/app/api/chain/[chain]/follow-on/route.ts new file mode 100644 index 00000000..006f0ce6 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/follow-on/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { assertFollowOnCanRun } from '../../../../../lib/chains/prepare' +import { chainModule } from '../../../../../lib/chains/registry' +import { ChainActionRefused } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' +import { isWalletAddress } from '../../../../../lib/wallet/address' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const NOTHING_MOVED = 'Nothing was signed or sent.' + +const Body = z.object({ + account: z.string().min(1).max(128), + step: z.object({ + kind: z.enum(['supply', 'stake', 'send']), + tokenIn: z.string().min(1).max(16), + venue: z.string().min(1).max(40).optional(), + recipient: z.string().min(1).max(128).optional(), + }), +}) + +/** Whether a step could run after a swap, asked before the swap is signed. Builds nothing. */ +export async function POST( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const parsed = Body.safeParse(await request.json().catch(() => undefined)) + if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + if (!isWalletAddress(parsed.data.account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + try { + const { kind, tokenIn, venue, recipient } = parsed.data.step + await assertFollowOnCanRun( + chainApi, + { + kind, + tokenIn, + ...(venue !== undefined ? { venue } : {}), + ...(recipient !== undefined ? { recipient } : {}), + }, + parsed.data.account + ) + return NextResponse.json({ ok: true }) + } catch (e) { + if (e instanceof ChainActionRefused) { + const said = e.message.endsWith(NOTHING_MOVED) ? e.message : `${e.message} ${NOTHING_MOVED}` + return NextResponse.json({ error: said }, { status: 422 }) + } + reportError('chain/follow-on', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'unreadable' }, { status: 502 }) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/orders/route.ts b/apps/dapp/app/api/chain/[chain]/orders/route.ts new file mode 100644 index 00000000..1c95965b --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/orders/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { chainModule } from '../../../../../lib/chains/registry' +import { BALANCE_DEADLINE_MS, ChainActionRefused } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' +import { isWalletAddress } from '../../../../../lib/wallet/address' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 20 + +const Body = z.object({ + account: z.string().min(1).max(128), + orderId: z.string().min(1).max(200), + action: z.enum(['cancel', 'collect']), +}) + +/** The account's resting limit orders on this chain, read when asked and never kept. */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const account = new URL(request.url).searchParams.get('account') ?? '' + if (!isWalletAddress(account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + if (chainApi.limitBook === undefined) { + return NextResponse.json({ orders: [] }, { headers: { 'cache-control': 'no-store' } }) + } + try { + const orders = await chainApi.limitBook.openOrders( + account, + AbortSignal.timeout(BALANCE_DEADLINE_MS) + ) + return NextResponse.json({ orders }, { headers: { 'cache-control': 'no-store' } }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + reportError('chain/orders', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'orders_unavailable' }, { status: 503 }) + } +} + +/** Builds the cancel or the collect of a resting order for the wallet to sign. */ +export async function POST( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const parsed = Body.safeParse(await request.json().catch(() => undefined)) + if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + if (!isWalletAddress(parsed.data.account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + if (chainApi.limitBook === undefined) { + return NextResponse.json( + { error: 'There are no limit orders to change on this chain. Nothing was signed or sent.' }, + { status: 422 } + ) + } + try { + const { account, orderId, action } = parsed.data + const prepared = await chainApi.limitBook.prepareExit(account, orderId, action) + const venue = chainApi.venues.find((v) => v.id === prepared.venueId) + if (venue === undefined) { + throw new ChainActionRefused( + 'That order is not one I can build safely. Nothing was signed or sent.' + ) + } + venue.assertSafe(prepared.signing, account, prepared.context) + return NextResponse.json({ prepared }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ error: e.message }, { status: 422 }) + } + reportError('chain/orders', e, { chain: chainApi.chain }) + return NextResponse.json( + { error: 'This could not be built right now. Nothing was signed or sent.' }, + { status: 502 } + ) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/positions/route.ts b/apps/dapp/app/api/chain/[chain]/positions/route.ts new file mode 100644 index 00000000..e0130775 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/positions/route.ts @@ -0,0 +1,81 @@ +import { isAddress as isSolanaAddress } from '@solana/kit' +import { NextResponse } from 'next/server' + +import { NEAR_ACCOUNT } from '../../../../../lib/chains/near/account' +import type { PositionsReport, VenueHoldings } from '../../../../../lib/chains/positions-reply' +import { readPositionsBriefly } from '../../../../../lib/chains/prepare' +import { chainModule } from '../../../../../lib/chains/registry' +import type { ChainActionKind, ChainWithActions } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 20 + +const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/ +const SUI_ADDRESS = /^0x[0-9a-fA-F]{64}$/ + +/** The same shapes the balance readers hold to. Every read swallows its error, so a bad address has to stop here. */ +function isAccountOn(chain: ChainWithActions, account: string): boolean { + switch (chain) { + case 'solana': + return isSolanaAddress(account) + case 'sui': + return SUI_ADDRESS.test(account) + case 'near': + return NEAR_ACCOUNT.test(account) + case 'avalanche': + case 'monad': + return EVM_ADDRESS.test(account) + } +} + +/** A venue that only swaps or sends keeps nothing, so one with no reader is left out, not called unread. */ +const HOLDS_MONEY: ReadonlySet = new Set([ + 'supply', + 'stake', + 'withdraw', + 'unstake', + 'borrow', + 'repay', + 'stream', + 'claim', + 'cancel', +]) + +/** What the account holds at each venue on this chain, read when asked and never kept. */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const account = new URL(request.url).searchParams.get('account') ?? '' + if (!isAccountOn(chainApi.chain, account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + const asked = chainApi.venues.filter( + (venue) => venue.positions !== undefined || venue.kinds.some((kind) => HOLDS_MONEY.has(kind)) + ) + const readings = await Promise.all(asked.map((venue) => readPositionsBriefly(venue, account))) + + const venues: VenueHoldings[] = [] + const unread: PositionsReport['unread'] = [] + asked.forEach(({ id, name, kinds }, i) => { + const positions = readings[i] + // A figure that isn't a number says nothing about what is there, so it isn't read as nothing. + if (positions === undefined || positions.some((p) => Number.isNaN(Number(p.amount)))) { + unread.push({ id, name }) + return + } + venues.push({ id, name, kinds, positions: positions.filter((p) => Number(p.amount) > 0) }) + }) + + const report: PositionsReport = { venues, unread } + return NextResponse.json(report, { headers: { 'cache-control': 'no-store' } }) +} diff --git a/apps/dapp/app/api/chain/[chain]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts index bcbc7722..28f8015c 100644 --- a/apps/dapp/app/api/chain/[chain]/prepare/route.ts +++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts @@ -14,14 +14,47 @@ export const dynamic = 'force-dynamic' const Body = z.object({ account: z.string().min(1).max(128), request: z.object({ - kind: z.enum(['swap', 'supply', 'stake']), + kind: z.enum([ + 'swap', + 'supply', + 'stake', + 'send', + 'limit', + 'withdraw', + 'unstake', + 'borrow', + 'repay', + 'stream', + 'invoice', + 'pay_invoice', + 'claim', + 'cancel', + ]), amount: z .string() - .regex(/^\d+(\.\d+)?$/) + .regex(/^(\d+(\.\d+)?|all)$/) .max(40), tokenIn: z.string().min(1).max(16), tokenOut: z.string().min(1).max(16).optional(), venue: z.string().min(1).max(40).optional(), + recipient: z.string().min(1).max(128).optional(), + limitPrice: z.string().min(1).max(40).optional(), + durationDays: z + .string() + .regex(/^[1-9]\d{0,4}$/) + .optional(), + dueDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + invoiceId: z + .string() + .regex(/^\d{1,78}$/) + .optional(), + streamId: z + .string() + .regex(/^[A-Za-z0-9]{1,64}$/) + .optional(), }), }) @@ -43,7 +76,19 @@ export async function POST( } try { - const { kind, amount, tokenIn, tokenOut, venue } = parsed.data.request + const { + kind, + amount, + tokenIn, + tokenOut, + venue, + recipient, + limitPrice, + durationDays, + dueDate, + invoiceId, + streamId, + } = parsed.data.request const prepared = await prepareChainAction( chainApi, { @@ -52,6 +97,12 @@ export async function POST( tokenIn, ...(tokenOut !== undefined ? { tokenOut } : {}), ...(venue !== undefined ? { venue } : {}), + ...(recipient !== undefined ? { recipient } : {}), + ...(limitPrice !== undefined ? { limitPrice } : {}), + ...(durationDays !== undefined ? { durationDays } : {}), + ...(dueDate !== undefined ? { dueDate } : {}), + ...(invoiceId !== undefined ? { invoiceId } : {}), + ...(streamId !== undefined ? { streamId } : {}), }, parsed.data.account ) diff --git a/apps/dapp/app/api/chain/[chain]/price/route.ts b/apps/dapp/app/api/chain/[chain]/price/route.ts new file mode 100644 index 00000000..7a7fa4cf --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/price/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' + +import { chainModule } from '../../../../../lib/chains/registry' +import { tokenBySymbol } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** A listed token's dollar price, from the same source the race uses, to size "$5 of SOL". */ +export async function GET( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'read') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const token = tokenBySymbol(chainApi.tokens, new URL(request.url).searchParams.get('token') ?? '') + if (token === undefined) return NextResponse.json({ error: 'unknown_token' }, { status: 404 }) + + try { + const priceUsd = await chainApi.priceUsd(token) + if (priceUsd === undefined) { + return NextResponse.json({ error: 'price_unavailable' }, { status: 503 }) + } + return NextResponse.json( + { priceUsd, decimals: token.decimals }, + { headers: { 'cache-control': 'no-store' } } + ) + } catch (e) { + reportError('chain/price', e, { chain: chainApi.chain }) + return NextResponse.json({ error: 'price_unavailable' }, { status: 503 }) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index 0d58363b..b39d46b8 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -1,3 +1,4 @@ +import { CHAIN_DESCRIPTORS } from '@intent/config' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -10,6 +11,8 @@ import { isWalletAddress } from '../../../../../lib/wallet/address' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +// A send waits for the network to confirm, and a 1Click swap then waits on 1Click too. +export const maxDuration = 60 const Signed = z.discriminatedUnion('family', [ z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }), @@ -22,6 +25,9 @@ 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) }), + z.object({ family: z.literal('evm-typed'), signature: z.string().min(1).max(200) }), ]) const Body = z.object({ @@ -54,20 +60,39 @@ 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. A typed message is EVM's too. + const family = signed.family === 'evm-typed' ? 'evm' : signed.family + if (family !== CHAIN_DESCRIPTORS[chainApi.chain].family) { return NextResponse.json({ error: 'wrong_chain' }, { status: 400 }) } - try { - // A NEAR wallet has already sent its transactions, so there are no bytes left to check. - if (signed.family !== 'near') { - const payload: SigningPayload = - signed.family === 'solana' - ? { family: 'solana', transaction: signed.signedTransaction } - : { family: 'sui', transaction: signed.transaction } - venue.assertSafe(payload, account) + // NEAR and EVM wallets have already sent, so there are no bytes left to check. Their venues + // ask the chain what was sent instead. A typed message carries only its signature, so its venue + // rebuilds what was signed and checks it before posting anything. + if (signed.family === 'solana' || signed.family === 'sui') { + const payload: SigningPayload = + signed.family === 'solana' + ? { family: 'solana', transaction: signed.signedTransaction } + : { family: 'sui', transaction: signed.transaction } + try { + venue.assertSafe(payload, account, context) + } catch (e) { + // This route is what sends the bytes, so any failure here means they never left. + if (e instanceof ChainActionRefused) { + return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) + } + reportError('chain/submit', e, { chain: chainApi.chain }) + return NextResponse.json( + { + ok: false, + error: 'This transaction could not be checked, so nothing was sent. Build it again.', + }, + { status: 422 } + ) } + } + try { const input = { account, venueId, signed, ...(context !== undefined ? { context } : {}) } const result = await (venue.submit ?? chainApi.submit)(input) await logExecution({ @@ -82,17 +107,17 @@ export async function POST( status: result.ok ? 200 : result.pending === true ? 202 : 422, }) } catch (e) { - if (e instanceof ChainActionRefused) { - return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) - } reportError('chain/submit', e, { chain: chainApi.chain }) return NextResponse.json( { ok: false, + // Sending may have started, so no throw from here on can prove nothing moved. + // Sent with no outcome known: the same contract as the cash-out route. + pending: true, error: "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", }, - { status: 502 } + { status: 202 } ) } } diff --git a/apps/dapp/app/api/intent/orchestrate/route.ts b/apps/dapp/app/api/intent/orchestrate/route.ts index 8c4f7978..b206bab4 100644 --- a/apps/dapp/app/api/intent/orchestrate/route.ts +++ b/apps/dapp/app/api/intent/orchestrate/route.ts @@ -3,13 +3,17 @@ import { z } from 'zod' import { LENDABLE_ONLY, + chainReadPolicy, + tokenHintIn, OFFRAMP_ANCHORS, lendingVenuesOn, unknownTickers, } from '../../../../lib/intent-read-policy' +import { SEQUENCE_CASHOUT_CHAINS } from '../../../../lib/chains/sequence' import { orchestrateWithLlm } from '../../../../lib/orchestrate/orchestrate' import { isLlmParseConfigured } from '../../../../lib/parse-intent-llm' import { logIntentRead } from '../../../../lib/server/analytics' +import { cashOutPrompt } from '../../../../lib/weave/corridors' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { resolveVerifiedAsset, tradeableSymbols } from '../../../../lib/swap/asset-registry' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' @@ -74,9 +78,12 @@ export async function POST(request: Request): Promise { // The latest thing the user typed is what names a token for the first time. const latest = [...turns].reverse().find((t) => t.role === 'user')?.content ?? '' + // Off Stellar a reading is held to the chain's own tokens and venues, not Stellar's registry. + const onChain = chainReadPolicy(chain) + // A token named in the sentence that the app does not hold by hand is looked // up and, only if its issuer proves itself, admitted for this request. - await ensureLiveAssets(tickersIn(latest)) + if (onChain === undefined) await ensureLiveAssets(tickersIn(latest)) const result = await orchestrateWithLlm(turns, { chain, @@ -85,15 +92,29 @@ export async function POST(request: Request): Promise { // The model may only name assets this app can actually trade. Checked here // rather than trusted, because a hallucinated ticker reaching the quoter as // a real instruction is a trap this codebase has hit once already. - allowedSymbols: [...tradeableSymbols(), ...(LENDABLE_ONLY[chain] ?? [])], - allowedVenues: lendingVenuesOn(chain), - allowedAnchors: OFFRAMP_ANCHORS[chain] ?? [], + allowedSymbols: onChain?.symbols ?? [...tradeableSymbols(), ...(LENDABLE_ONLY[chain] ?? [])], + allowedVenues: onChain?.venues ?? lendingVenuesOn(chain), + // The only payout off Stellar is Weave's, and only on a chain whose swaps can end in one. + allowedAnchors: + onChain === undefined + ? (OFFRAMP_ANCHORS[chain] ?? []) + : (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) && + cashOutPrompt(chain) !== '' + ? ['weave'] + : [], + // Off Stellar a follow-on that can't be honoured is refused before the swap, not dropped. + ...(onChain !== undefined ? { reportRefusedFollowOn: true } : {}), }) if (result === null) { // A ticker typed in capitals that nothing here recognises is most likely // the reason. Saying so lets the chat ask which token was meant. - const unknown = unknownTickers(latest) + const hint = onChain === undefined ? undefined : tokenHintIn(chain, latest) + if (hint !== undefined) { + await logIntentRead({ understood: false, reason: 'unclear' }) + return NextResponse.json({ outcome: 'unclear', reply: hint }) + } + const unknown = onChain === undefined ? unknownTickers(latest) : [] if (unknown.length > 0) { await logIntentRead({ understood: false, reason: 'unknown_token' }) return NextResponse.json({ outcome: 'unavailable', reason: 'unknown_token', tokens: unknown }) @@ -164,16 +185,22 @@ export async function POST(request: Request): Promise { // Null rather than omitted, so the client distinguishes "no follow-on" // from a field that failed to serialise. followOn: reading.followOn, + ...(reading.followOnRefused !== undefined + ? { followOnRefused: reading.followOnRefused } + : {}), style: result.style, ...(result.amountOf !== undefined ? { amountOf: result.amountOf } : {}), ...(result.limitPriceUsd !== undefined ? { limitPriceUsd: result.limitPriceUsd } : {}), instruction: result.instruction, context: result.context, // Tokens admitted live, so the page can trade them without a second lookup. - liveAssets: [reading.tokenIn, reading.tokenOut].flatMap((symbol) => { - const asset = resolveVerifiedAsset(symbol) - return asset !== undefined && asset.category === 'token' ? [asset] : [] - }), + liveAssets: + onChain !== undefined + ? [] + : [reading.tokenIn, reading.tokenOut].flatMap((symbol) => { + const asset = resolveVerifiedAsset(symbol) + return asset !== undefined && asset.category === 'token' ? [asset] : [] + }), }) } } diff --git a/apps/dapp/app/api/lend/borrow/route.ts b/apps/dapp/app/api/lend/borrow/route.ts index 58044d7a..d42f80cb 100644 --- a/apps/dapp/app/api/lend/borrow/route.ts +++ b/apps/dapp/app/api/lend/borrow/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server' import { buildBlendBorrow, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { explainPoolError, isTransientPoolError } from '../../../../lib/lend/pool-errors' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -72,16 +71,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // A borrow adds exposure the same as a supply does, so it is capped the same. - try { - await assertReserveWithinCap(body.asset, body.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - try { const built = await buildBlendBorrow({ account: body.account, diff --git a/apps/dapp/app/api/lend/build/route.ts b/apps/dapp/app/api/lend/build/route.ts index 7d4ca164..1698a2a1 100644 --- a/apps/dapp/app/api/lend/build/route.ts +++ b/apps/dapp/app/api/lend/build/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server' import { buildBlendSupply, prepareBlendSupply } from '../../../../lib/lend/blend-client' import { explainPoolError } from '../../../../lib/lend/pool-errors' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' @@ -64,17 +63,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // Against the mainnet cap; see `assertReserveWithinCap` for how a reserve is - // priced. - try { - await assertReserveWithinCap(body.asset, body.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - try { const built = await buildBlendSupply({ account: body.account, diff --git a/apps/dapp/app/api/lend/collateral/route.ts b/apps/dapp/app/api/lend/collateral/route.ts index 40d09db1..1138c0a5 100644 --- a/apps/dapp/app/api/lend/collateral/route.ts +++ b/apps/dapp/app/api/lend/collateral/route.ts @@ -5,7 +5,6 @@ import { buildBlendCollateralWithdraw, prepareBlendWithdraw, } from '../../../../lib/lend/blend-client' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' import { explainPoolError } from '../../../../lib/lend/pool-errors' import { readReserveList } from '../../../../lib/lend/reserves' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -32,7 +31,7 @@ interface CollateralBody { asset?: string /** Base units. Required to post; omit when reclaiming everything. */ amount?: string - /** 'post' adds collateral, 'reclaim' returns it to a plain supply balance. */ + /** 'post' moves it from the wallet into the pool, 'reclaim' pays it back to the wallet. */ direction?: 'post' | 'reclaim' } @@ -75,19 +74,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) } - // Posting moves the user's funds into the pool, so it is capped. Reclaiming - // returns them to a plain supply balance of the same account and is not. - if (body.direction === 'post') { - try { - await assertReserveWithinCap(body.asset, body.amount as string) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - } - try { const built = body.direction === 'post' diff --git a/apps/dapp/app/api/lend/defindex/build/route.ts b/apps/dapp/app/api/lend/defindex/build/route.ts index 5b0a3300..fbc0d335 100644 --- a/apps/dapp/app/api/lend/defindex/build/route.ts +++ b/apps/dapp/app/api/lend/defindex/build/route.ts @@ -29,10 +29,6 @@ interface Body { amount?: unknown } -// No mainnet trade cap here. This venue is testnet-only (`networks` in -// lib/venues.ts) and its builder refuses any other network, so nothing is -// spent on mainnet through this route. Give it `assertTradeWithinCap` before -// the venue gains `networks: ['mainnet']`. export async function POST(request: Request): Promise { const limited = await enforceRateLimit(request, 'build') if (limited !== undefined) return limited diff --git a/apps/dapp/app/api/lend/repay/route.ts b/apps/dapp/app/api/lend/repay/route.ts index 44f9985a..62a9863f 100644 --- a/apps/dapp/app/api/lend/repay/route.ts +++ b/apps/dapp/app/api/lend/repay/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server' import { buildBlendRepay, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { assertReserveWithinCap } from '../../../../lib/lend/cap' -import { explainPoolError } from '../../../../lib/lend/pool-errors' -import { readReserveList } from '../../../../lib/lend/reserves' +import { findDebt, planRepayAll, readSpendable, worthOf } from '../../../../lib/lend/exit' +import { explainExitError } from '../../../../lib/lend/pool-errors' +import { readBlendPositions, type BlendPositions } from '../../../../lib/lend/position' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' @@ -15,17 +15,19 @@ import { reportError } from '../../../../lib/server/report' * user dependent on somebody else's interface. For a debt that is worse than * for a supply, because the balance grows while they look for one. * - * Omitting the amount repays everything, and that is the path worth - * encouraging. A debt read a moment ago is already larger, so repaying an - * exact figure leaves a remainder that keeps accruing — and keeps the position - * liquidatable for a sum the user believed they had cleared. + * Omitting the amount repays everything the wallet can cover. The figure is + * worked out here from the debt read now, and the pool refunds what is over: see + * `repayAllAmount`. */ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' interface RepayBody { account?: string + /** The reserve's asset, as a contract id. */ asset?: string + /** A ticker, when a person typed one instead. */ + symbol?: string /** Base units, or omitted to clear the whole liability. */ amount?: string } @@ -44,58 +46,81 @@ export async function POST(request: Request): Promise { if (typeof body.account !== 'string' || body.account === '') { return NextResponse.json({ error: 'account_required' }, { status: 400 }) } - if (typeof body.asset !== 'string' || body.asset === '') { + const asset = typeof body.asset === 'string' && body.asset !== '' ? body.asset : undefined + const symbol = typeof body.symbol === 'string' && body.symbol !== '' ? body.symbol : undefined + if (asset === undefined && symbol === undefined) { return NextResponse.json({ error: 'asset_required' }, { status: 400 }) } - if (body.amount !== undefined && (typeof body.amount !== 'string' || body.amount === '')) { + if ( + body.amount !== undefined && + (typeof body.amount !== 'string' || !/^[1-9]\d*$/.test(body.amount)) + ) { return NextResponse.json({ error: 'amount_invalid' }, { status: 400 }) } + let positions: BlendPositions try { - const accepted = await readReserveList() - if (!accepted.includes(body.asset)) { - return NextResponse.json( - { error: 'Blend has no reserve for this asset, so nothing can be owed on it.' }, - { status: 400 } - ) - } + positions = await readBlendPositions(body.account) } catch (e) { - reportError('lend/repay', e, { account: body.account, asset: body.asset }) - return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) + reportError('lend/repay', e, { account: body.account, asset }) + return NextResponse.json( + { error: 'Could not read what you owe in Blend. Nothing was signed or sent.' }, + { status: 502 } + ) + } + + const debt = findDebt(positions, { + ...(asset !== undefined ? { assetId: asset } : {}), + ...(symbol !== undefined ? { symbol } : {}), + }) + if ('refusal' in debt) { + return NextResponse.json({ error: debt.refusal }, { status: 400 }) } - // An omitted amount clears the whole liability, which cannot be valued ahead - // of time. That liability can only have come from a borrow this cap already - // bounded, so repaying it is not a way to move more than the cap. - if (body.amount !== undefined) { + let amount = body.amount + if (amount === undefined) { + let spendable: bigint try { - await assertReserveWithinCap(body.asset, body.amount) + spendable = await readSpendable(body.account, debt.assetId) } catch (e) { + reportError('lend/repay', e, { account: body.account, asset: debt.assetId }) return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } + { error: 'Could not read your wallet balance. Nothing was signed or sent.' }, + { status: 502 } ) } + const plan = planRepayAll(BigInt(debt.amount), spendable, debt.symbol) + if ('refusal' in plan) { + return NextResponse.json({ error: plan.refusal }, { status: 400 }) + } + amount = plan.amount } try { - const built = await buildBlendRepay({ - account: body.account, - asset: body.asset, - ...(body.amount !== undefined ? { amount: body.amount } : {}), - }) + const built = await buildBlendRepay({ account: body.account, asset: debt.assetId, amount }) const prepared = await prepareBlendWithdraw(built.xdr) if (!prepared.ok) { - return NextResponse.json({ error: explainPoolError(prepared.reason) }, { status: 400 }) + return NextResponse.json( + { error: explainExitError(prepared.reason, 'repay') }, + { status: 400 } + ) } + // Decided by what the pool says would remain, not by what was asked for: a wallet that holds + // less than the debt, asked to "repay all", repays part of it and must be told so. + const remaining = + prepared.after !== undefined ? worthOf(debt, prepared.after.liabilities) : undefined + return NextResponse.json({ xdr: prepared.xdr, asset: built.asset, + symbol: debt.symbol, amount: built.amount, - everything: built.everything, + owed: debt.amount, + everything: remaining === '0', recipient: built.recipient, + ...(remaining !== undefined ? { remaining } : {}), }) } catch (e) { return NextResponse.json( diff --git a/apps/dapp/app/api/lend/withdraw/route.ts b/apps/dapp/app/api/lend/withdraw/route.ts index 19055185..41474ae9 100644 --- a/apps/dapp/app/api/lend/withdraw/route.ts +++ b/apps/dapp/app/api/lend/withdraw/route.ts @@ -1,23 +1,28 @@ import { NextResponse } from 'next/server' -import { buildBlendWithdraw, prepareBlendWithdraw } from '../../../../lib/lend/blend-client' -import { explainPoolError } from '../../../../lib/lend/pool-errors' -import { readReserveList } from '../../../../lib/lend/reserves' +import { + buildBlendCollateralWithdraw, + buildBlendWithdraw, + buildBlendWithdrawEverywhere, + prepareBlendWithdraw, +} from '../../../../lib/lend/blend-client' +import { planWithdraw, worthOf } from '../../../../lib/lend/exit' +import { explainExitError } from '../../../../lib/lend/pool-errors' +import { readBlendPositions, type BlendPositions } from '../../../../lib/lend/position' import { enforceRateLimit } from '../../../../lib/server/rate-limit' import { reportError } from '../../../../lib/server/report' /** * Builds a withdrawal from Blend, ready for signature. * - * The mirror of the supply builder, and simulated for the same reason: Soroban - * needs the simulation to compute a resource footprint, and it doubles as the - * feasibility check. + * Read from the position first, because a person names an asset and the pool + * holds it in one of two forms: a plain supply and collateral are different + * request types, and asking for the wrong one is refused as "nothing supplied" + * over money that is really there. Both forms pay the wallet. * - * What it catches here is different, though. A supply can fail on a cap or a - * balance; a withdrawal fails when the reserve has lent out too much of what it - * holds. Blend refuses a withdrawal that would push utilisation past `max_util` - * — 95% in this pool, which sits around 90% — so a perfectly real position can - * be temporarily larger than the reserve can pay out. Surfacing the pool's own + * The simulation is the mirror of the supply builder's, and what it catches + * differs: a withdrawal fails when the reserve has lent out too much of what it + * holds, or when collateral is still backing a loan. Surfacing the pool's own * revert reason says which of those happened. */ export const runtime = 'nodejs' @@ -27,6 +32,8 @@ interface WithdrawBody { account?: string /** The reserve's asset, as a contract id. */ asset?: string + /** A ticker, when a person typed one instead. */ + symbol?: string /** * Base units, or omitted to empty the position. * @@ -35,11 +42,13 @@ interface WithdrawBody { * the pool clamps to whatever is actually there. */ amount?: string + /** Which form to take from. Omitted, the position decides. */ + from?: 'supply' | 'collateral' } -// No mainnet trade cap here: a withdrawal returns the account's own position -// to the account (`assertSelfPoolCall` pins `from`, `spender` and `to` to the -// signer), so it cannot move value anywhere the user did not already hold it. +// A withdrawal returns the account's own position to the account +// (`assertSelfPoolCall` pins `from`, `spender` and `to` to the signer), so it +// cannot move value anywhere the user did not already hold it. export async function POST(request: Request): Promise { const limited = await enforceRateLimit(request, 'build') if (limited !== undefined) return limited @@ -54,50 +63,81 @@ export async function POST(request: Request): Promise { if (typeof body.account !== 'string' || body.account === '') { return NextResponse.json({ error: 'account_required' }, { status: 400 }) } - if (typeof body.asset !== 'string' || body.asset === '') { + const asset = typeof body.asset === 'string' && body.asset !== '' ? body.asset : undefined + const symbol = typeof body.symbol === 'string' && body.symbol !== '' ? body.symbol : undefined + if (asset === undefined && symbol === undefined) { return NextResponse.json({ error: 'asset_required' }, { status: 400 }) } // Absent means "everything". An empty string is a client bug rather than an // intention, so it is refused instead of being read as the sentinel. - if (body.amount !== undefined && (typeof body.amount !== 'string' || body.amount === '')) { + if ( + body.amount !== undefined && + (typeof body.amount !== 'string' || !/^[1-9]\d*$/.test(body.amount)) + ) { return NextResponse.json({ error: 'amount_invalid' }, { status: 400 }) } + if (body.from !== undefined && body.from !== 'supply' && body.from !== 'collateral') { + return NextResponse.json({ error: 'from_invalid' }, { status: 400 }) + } + let positions: BlendPositions try { - const accepted = await readReserveList() - if (!accepted.includes(body.asset)) { - return NextResponse.json( - { error: 'Blend has no reserve for this asset, so there is nothing to withdraw.' }, - { status: 400 } - ) - } + positions = await readBlendPositions(body.account) } catch (e) { - reportError('lend/withdraw', e, { account: body.account, asset: body.asset }) - return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 }) + reportError('lend/withdraw', e, { account: body.account, asset }) + return NextResponse.json( + { error: 'Could not read your Blend position. Nothing was signed or sent.' }, + { status: 502 } + ) + } + + const plan = planWithdraw(positions, { + ...(asset !== undefined ? { assetId: asset } : {}), + ...(symbol !== undefined ? { symbol } : {}), + ...(body.amount !== undefined ? { amount: body.amount } : {}), + ...(body.from !== undefined ? { from: body.from } : {}), + }) + if ('refusal' in plan) { + return NextResponse.json({ error: plan.refusal }, { status: 400 }) } try { - const built = await buildBlendWithdraw({ + const options = { account: body.account, - asset: body.asset, - ...(body.amount !== undefined ? { amount: body.amount } : {}), - }) + asset: plan.assetId, + ...(plan.amount !== undefined ? { amount: plan.amount } : {}), + } + const built = + plan.from === 'both' + ? await buildBlendWithdrawEverywhere(options) + : plan.from === 'collateral' + ? await buildBlendCollateralWithdraw(options) + : await buildBlendWithdraw(options) const prepared = await prepareBlendWithdraw(built.xdr) if (!prepared.ok) { - return NextResponse.json({ error: explainPoolError(prepared.reason) }, { status: 400 }) + return NextResponse.json( + { error: explainExitError(prepared.reason, 'withdraw') }, + { status: 400 } + ) } + const supplyLeft = worthOf(plan.supplied, prepared.after?.supply) + const collateralLeft = worthOf(plan.collateral, prepared.after?.collateral) + return NextResponse.json({ xdr: prepared.xdr, asset: built.asset, + symbol: plan.symbol, + from: plan.from, amount: built.amount, everything: built.everything, recipient: built.recipient, - // What the position still holds afterwards — not the sum withdrawn. On a - // full withdrawal this is zero, which is the clearest confirmation the - // simulation can give that the position will actually be emptied. - ...(prepared.remaining !== undefined ? { remaining: prepared.remaining } : {}), + // What stays behind in the form this took from: not the sum withdrawn. Zero is the + // clearest confirmation the simulation can give that it will actually be emptied. + ...(prepared.after !== undefined + ? { remaining: plan.from === 'collateral' ? collateralLeft : supplyLeft } + : {}), }) } catch (e) { return NextResponse.json( diff --git a/apps/dapp/app/api/offers/build/route.ts b/apps/dapp/app/api/offers/build/route.ts index eca31235..997a0f03 100644 --- a/apps/dapp/app/api/offers/build/route.ts +++ b/apps/dapp/app/api/offers/build/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server' import { reportError } from '../../../../lib/server/report' -import { resolveAsset } from '../../../../lib/swap/assets' +import { resolveAsset, type ClassicAsset } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { buildOfferTransaction } from '../../../../lib/swap/build-offer' import { fetchOrderBookTop, offerPriceFromUsd } from '../../../../lib/swap/limit-price' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' +import { fetchOffer, type HorizonOfferRecord } from '../../../../lib/swap/offers' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -39,6 +39,11 @@ interface BuildOfferBody { offerId?: string } +function ledgerAsset(asset: HorizonOfferRecord['selling']): ClassicAsset { + if (asset.asset_type === 'native') return { kind: 'classic', code: 'XLM' } + return { kind: 'classic', code: asset.asset_code ?? '', issuer: asset.asset_issuer ?? '' } +} + export async function POST(req: Request): Promise { const limited = await enforceRateLimit(req, 'build') if (limited !== undefined) return limited @@ -55,33 +60,41 @@ export async function POST(req: Request): Promise { if (account === undefined || account.trim() === '') { return NextResponse.json({ error: 'account_required' }, { status: 400 }) } - if (sellSymbol === undefined || buySymbol === undefined || amount === undefined) { + if (amount === undefined) { return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) } - await ensureLiveAssets([sellSymbol, buySymbol]) - const selling = resolveAsset(sellSymbol) - const buying = resolveAsset(buySymbol) - if (selling === undefined || buying === undefined) { - // The allowlist is deliberately small: an intent is free text read by a - // model, and an unknown symbol must fail here rather than resolve to - // whatever issuer answers to the name. - return NextResponse.json({ error: 'unknown_asset' }, { status: 400 }) - } - - // Cancelling needs no price: an offer at zero size is removed whatever it - // was priced at, and requiring a price would mean re-deriving one just to - // throw it away. - const cancelling = Number(amount) === 0 - if (cancelling) { + // Cancelling is read from the ledger, not from the names a client sends. Getting an order back + // must not depend on this app's token list: one placed with a token that later left the list, + // or from another wallet app, would otherwise be listed here with no way to withdraw it. + if (Number(amount) === 0) { if (offerId === undefined || offerId === '0') { return NextResponse.json({ error: 'offer_id_required' }, { status: 400 }) } try { + const offer = await fetchOffer(offerId) + if (offer === undefined) { + return NextResponse.json( + { + error: 'offer_gone', + detail: 'This order is no longer open, so there is nothing to withdraw.', + }, + { status: 404 } + ) + } + if (offer.seller !== account) { + return NextResponse.json( + { + error: 'not_your_offer', + detail: 'This order belongs to another account. Nothing was signed or sent.', + }, + { status: 403 } + ) + } const built = await buildOfferTransaction({ account, - selling, - buying, + selling: ledgerAsset(offer.selling), + buying: ledgerAsset(offer.buying), amount: '0', // Any positive ratio serves; the operation is removing the order. price: { n: 1, d: 1 }, @@ -89,6 +102,7 @@ export async function POST(req: Request): Promise { }) return NextResponse.json({ xdr: built.xdr, offerId: built.offerId, cancelling: true }) } catch (e) { + reportError('offers/build', e, { offerId }) return NextResponse.json( { error: 'build_failed', detail: e instanceof Error ? e.message : undefined }, { status: 400 } @@ -96,18 +110,22 @@ export async function POST(req: Request): Promise { } } - if (typeof limitPriceUsd !== 'number' || !Number.isFinite(limitPriceUsd)) { - return NextResponse.json({ error: 'limit_price_required' }, { status: 400 }) + if (sellSymbol === undefined || buySymbol === undefined) { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) } - // What the order gives up, against the mainnet cap. - try { - await assertTradeWithinCap(selling.code, amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) + await ensureLiveAssets([sellSymbol, buySymbol]) + const selling = resolveAsset(sellSymbol) + const buying = resolveAsset(buySymbol) + if (selling === undefined || buying === undefined) { + // The allowlist is deliberately small: an intent is free text read by a + // model, and an unknown symbol must fail here rather than resolve to + // whatever issuer answers to the name. + return NextResponse.json({ error: 'unknown_asset' }, { status: 400 }) + } + + if (typeof limitPriceUsd !== 'number' || !Number.isFinite(limitPriceUsd)) { + return NextResponse.json({ error: 'limit_price_required' }, { status: 400 }) } // The book is read for the pair in its quoted orientation — XLM against diff --git a/apps/dapp/app/api/offramp/build/route.ts b/apps/dapp/app/api/offramp/build/route.ts index c1952a0d..f58d713e 100644 --- a/apps/dapp/app/api/offramp/build/route.ts +++ b/apps/dapp/app/api/offramp/build/route.ts @@ -4,7 +4,6 @@ import { buildOfframpPayment } from '../../../../lib/offramp/build-payment' import { readExpectation } from '../../../../lib/offramp/read-expectation' import { USDC, fromBaseUnits, toBaseUnits } from '../../../../lib/swap/assets' import { balanceOf } from '../../../../lib/swap/delivered-balance' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -70,16 +69,6 @@ export async function POST(request: Request): Promise { ) } - // The amount the anchor asks for, against the mainnet cap. - try { - await assertTradeWithinCap(USDC.code, read.expectation.amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - // The anchor may ask for more than the swap delivered. The app never rounds // up to meet it: the figures are named and the user decides. try { diff --git a/apps/dapp/app/api/plan/build/route.ts b/apps/dapp/app/api/plan/build/route.ts index 9bdaa5d2..d73942ad 100644 --- a/apps/dapp/app/api/plan/build/route.ts +++ b/apps/dapp/app/api/plan/build/route.ts @@ -8,7 +8,6 @@ import { applySlippage } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { collectQuotes } from '../../../../lib/swap/quote' import { createHorizonQuoter } from '../../../../lib/swap/sources/horizon-quoter' -import { assertPlanWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -66,9 +65,6 @@ export async function POST(request: Request): Promise { try { const built = await buildPlan({ account: body.account, actions }) - // Step by step against the mainnet cap, once the plan has been built, so - // a step whose venue is not on this network is refused by name first. - await assertPlanWithinCap(actions) return NextResponse.json({ xdr: built.xdr, steps: built.steps, diff --git a/apps/dapp/app/api/pools/route.ts b/apps/dapp/app/api/pools/route.ts new file mode 100644 index 00000000..6d66b8f3 --- /dev/null +++ b/apps/dapp/app/api/pools/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../lib/server/rate-limit' +import { reportError } from '../../../lib/server/report' +import { readPoolShares } from '../../../lib/swap/liquidity-pools' + +/** An account's shares in Stellar's built-in liquidity pools, valued against live reserves. */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(req: Request): Promise { + const limited = await enforceRateLimit(req, 'resolve') + if (limited !== undefined) return limited + + const account = new URL(req.url).searchParams.get('account') + if (account === null || account.trim() === '') { + return NextResponse.json({ error: 'account_required' }, { status: 400 }) + } + + try { + return NextResponse.json({ shares: await readPoolShares(account) }) + } catch (e) { + // Never an empty list: "you hold no shares" must not stand in for "could not find out". + reportError('pools', e, { account }) + return NextResponse.json({ error: 'horizon_unreachable' }, { status: 502 }) + } +} diff --git a/apps/dapp/app/api/pools/submit/route.ts b/apps/dapp/app/api/pools/submit/route.ts new file mode 100644 index 00000000..0e823c3b --- /dev/null +++ b/apps/dapp/app/api/pools/submit/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { logExecution } from '../../../../lib/server/analytics' +import { sponsorForRequest } from '../../../../lib/sponsor/sponsor-request' +import { assertSelfPoolWithdraw } from '../../../../lib/swap/build-pool' +import { submitSignedSwap } from '../../../../lib/swap/submit' + +/** + * Submits a signed pool withdrawal, re-checked because between build and here it + * passed through the client and a browser extension. + */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(request: Request): Promise { + const limited = await enforceRateLimit(request, 'submit') + if (limited !== undefined) return limited + + let body: { signedXdr?: unknown; account?: unknown } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + + if (typeof body.signedXdr !== 'string' || body.signedXdr === '') { + return NextResponse.json({ error: 'signedXdr is required' }, { status: 400 }) + } + if (typeof body.account !== 'string' || body.account === '') { + return NextResponse.json({ error: 'account is required' }, { status: 400 }) + } + + try { + assertSelfPoolWithdraw(body.signedXdr, body.account) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'refusing to submit' }, + { status: 400 } + ) + } + + const sent = await sponsorForRequest(request, body.signedXdr, body.account) + const result = await submitSignedSwap(sent.xdr) + await logExecution({ + kind: 'plan', + account: body.account, + feeSponsored: sent.sponsored, + result, + signedXdr: sent.xdr, + }) + return NextResponse.json({ ...result, feeSponsored: sent.sponsored }) +} diff --git a/apps/dapp/app/api/pools/withdraw/route.ts b/apps/dapp/app/api/pools/withdraw/route.ts new file mode 100644 index 00000000..f55d21c1 --- /dev/null +++ b/apps/dapp/app/api/pools/withdraw/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { buildPoolExit } from '../../../../lib/swap/build-pool' + +/** + * Builds a withdrawal of every share an account holds in one pool, ready to sign. + * + * The operation has no recipient field: both assets can only return to the + * account that signs it, and the network requires that account to hold a + * trustline for each for as long as it holds the shares. + */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const POOL_ID = /^[0-9a-f]{64}$/ + +export async function POST(request: Request): Promise { + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + let body: { account?: unknown; poolId?: unknown } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + + if (typeof body.account !== 'string' || body.account === '') { + return NextResponse.json({ error: 'account_required' }, { status: 400 }) + } + if (typeof body.poolId !== 'string' || !POOL_ID.test(body.poolId)) { + return NextResponse.json({ error: 'pool_required' }, { status: 400 }) + } + + try { + const built = await buildPoolExit({ account: body.account, poolId: body.poolId }) + return NextResponse.json({ + xdr: built.xdr, + poolId: built.poolId, + shares: built.shares, + floors: built.floors, + }) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'This withdrawal could not be built.' }, + { status: 400 } + ) + } +} diff --git a/apps/dapp/app/api/send/build/route.ts b/apps/dapp/app/api/send/build/route.ts index 21549a1d..edd57f65 100644 --- a/apps/dapp/app/api/send/build/route.ts +++ b/apps/dapp/app/api/send/build/route.ts @@ -15,7 +15,6 @@ import { resolveAsset } from '../../../../lib/swap/assets' import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets' import { derivePreview } from '../../../../lib/swap/preview' import { fetchMarketPrices } from '../../../../lib/swap/prices' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { enforceRateLimit } from '../../../../lib/server/rate-limit' /** @@ -100,16 +99,6 @@ export async function POST(request: Request): Promise { } } - // Against the mainnet cap, sized from the same table as the amount above. - try { - await assertTradeWithinCap(asset.code, amount) - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } - let expectation try { expectation = expectationFor(resolved, symbol, amount, memo) diff --git a/apps/dapp/app/api/swap/build/route.ts b/apps/dapp/app/api/swap/build/route.ts index 01db7d43..e937f2a3 100644 --- a/apps/dapp/app/api/swap/build/route.ts +++ b/apps/dapp/app/api/swap/build/route.ts @@ -18,10 +18,9 @@ import { buildSorobanSwap, prepareSorobanSwap } from '../../../../lib/swap/build import { createSoroswapApi } from '../../../../lib/swap/soroswap-api' import { createSoroswapAggregatorQuoter } from '../../../../lib/swap/sources/soroswap-aggregator-quoter' import { builderFor, type VenueKind } from '../../../../lib/swap/venue-routing' -import { applySlippage, fromBaseUnits } from '../../../../lib/swap/assets' +import { applySlippage } from '../../../../lib/swap/assets' import { ensureLiveAssets } from '../../../../lib/swap/live-assets' import { checkSlippageBps } from '../../../../lib/swap/slippage' -import { assertTradeWithinCap } from '../../../../lib/server/trade-cap' import { DEFAULT_SLIPPAGE_BPS } from '../../../../lib/swap/build-tx' import { enforceRateLimit } from '../../../../lib/server/rate-limit' @@ -140,18 +139,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'no_route', reason: fresh.failure.reason }, { status: 200 }) } - // The cap reads the fresh quote, never the client's figure. On a fixed - // output the client's send amount is a claim about nothing — the envelope - // spends up to the quoted input widened by the tolerance, so that ceiling - // is what is capped. - const slippageBps = slippage.bps ?? DEFAULT_SLIPPAGE_BPS - const spends = - fresh.quote.kind === 'strict_receive' - ? widen(fresh.quote.sendAmount, slippageBps) - : fresh.quote.sendAmount - const capped = await overCap(fresh.quote.from.code, spends) - if (capped !== undefined) return capped - try { const built = await buildSwapTransaction({ account: body.account, @@ -185,24 +172,6 @@ export async function POST(request: Request): Promise { } } -/** - * The mainnet cap, checked on what a fresh quote says will leave the account - * — in base units of the asset sent — and never on a figure the client sent. - * A no-op on testnet. Answers the refusal so each venue's builder can return - * it as is. - */ -async function overCap(symbol: string, baseAmount: string): Promise { - try { - await assertTradeWithinCap(symbol, fromBaseUnits(baseAmount)) - return undefined - } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : 'over the mainnet trade cap' }, - { status: 400 } - ) - } -} - /** * Builds a Soroswap swap and prepares it for signature. * @@ -235,9 +204,6 @@ async function buildViaSoroban( return NextResponse.json({ error: 'no_route', reason: fresh.failure.reason }, { status: 200 }) } - const capped = await overCap(fresh.quote.from.code, fresh.quote.sendAmount) - if (capped !== undefined) return capped - const minReceive = applySlippage(fresh.quote.destAmount, slippageBps) let built @@ -333,9 +299,6 @@ async function buildViaAggregator( return NextResponse.json({ error: 'no_route', reason: fresh.failure.reason }, { status: 200 }) } - const capped = await overCap(fresh.quoted.quote.from.code, fresh.quoted.quote.sendAmount) - if (capped !== undefined) return capped - let built try { built = await buildAggregatorSwap({ account, quoted: fresh.quoted, api: createSoroswapApi() }) @@ -429,9 +392,6 @@ async function buildViaAquarius( ) } - const capped = await overCap(fresh.from.code, fresh.sendAmount) - if (capped !== undefined) return capped - const minReceive = applySlippage(fresh.destAmount, slippageBps) let built @@ -506,9 +466,6 @@ async function buildViaPhoenix( ) } - const capped = await overCap(fresh.from.code, fresh.sendAmount) - if (capped !== undefined) return capped - const minReceive = applySlippage(fresh.destAmount, slippageBps) let built 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/connect/connect-dialog.tsx b/apps/dapp/components/connect/connect-dialog.tsx index 48daf659..2edfe84d 100644 --- a/apps/dapp/components/connect/connect-dialog.tsx +++ b/apps/dapp/components/connect/connect-dialog.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { isUserRejection, type WalletOption } from '../../lib/wallet/connector' import { connectorFor } from '../../lib/wallet/connectors' import { walletErrorMessage } from '../../lib/wallet/error-message' +import { ChainBetaTag } from '../layout/beta-chain-notice' const rowClass = 'hover:bg-muted/60 focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2' @@ -138,6 +139,7 @@ function ChainGrid({ onPick }: { onPick: (slug: ChainSlug) => void }): JSX.Eleme ))} diff --git a/apps/dapp/components/intents/borrow-confirm.tsx b/apps/dapp/components/intents/borrow-confirm.tsx index cb2f6e6b..98e89631 100644 --- a/apps/dapp/components/intents/borrow-confirm.tsx +++ b/apps/dapp/components/intents/borrow-confirm.tsx @@ -160,6 +160,19 @@ export function BorrowConfirm({ ? 'Clearing a debt returns the collateral behind it to your control.' : 'Borrowed against the collateral you have posted. Interest accrues from the moment it settles.'}

+ {direction === 'repay' && borrow.everything === true ? ( +

+ You owe about {display(borrow.owed) ?? ''} {borrow.asset ?? ''}. Your wallet may show{' '} + {display(borrow.amount) ?? ''}: interest keeps growing until this settles, so it asks + for slightly more, and the pool returns the difference in the same transaction. +

+ ) : null} + {direction === 'repay' && borrow.everything !== true && borrow.remaining !== undefined ? ( +

+ About {display(borrow.remaining) ?? ''} {borrow.asset ?? ''} stays owed and keeps + growing. +

+ ) : null} {/* The risk panel. Only meaningful for a borrow: a repayment can only diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx index 71992603..7f37437e 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -41,6 +41,7 @@ import { type CashoutOrderView, type CashoutQuote, } from '../../lib/weave/cashout-view' +import { currencyFlag, payoutWord } from '../../lib/weave/currency-flag' import { isCashoutChain } from '../../lib/weave/types' import { useChain } from '../../providers/chain-provider' import { TokenIcon } from '../ui/token-icon' @@ -297,7 +298,7 @@ function BeforeYouSign({ {expired ? 'This payout will be cancelled. Use Edit to start a new one.' - : 'Unpaid orders are cancelled. Nothing is taken.'} + : 'Unpaid orders are cancelled. Nothing is taken. Weave has not yet paid out a real order in this app.'} b.code === bankCode)?.name + const word = payoutWord(institutions.data?.map((i) => i.type) ?? []) + const holderWord = picked?.type === 'mobile_money' ? 'mobile money provider' : 'bank' + const flag = currencyFlag(currency) const creating = phase === 'creating' return ( @@ -669,7 +673,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu subtitle={ isNaira ? 'Paid straight to a Nigerian bank account' - : `Paid straight to a bank or mobile money account in ${corridor?.country ?? currency}` + : `Paid straight to a ${word} account in ${corridor?.country ?? currency}` } /> @@ -715,8 +719,18 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
{estimate !== undefined ? ( - + ≈{' '} + {flag !== undefined ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${flag.country} + ) : null} {formatPayout(estimate.destAmount, currency)} @@ -729,7 +743,9 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu {quote.isError && quoteReady ? (quote.error as Error).message - : 'Enter an amount to see what the bank receives.'} + : isNaira + ? 'Enter an amount to see what the bank receives.' + : 'Enter an amount to see what you receive.'} )}
@@ -742,7 +758,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
- Checking with the bank… + Checking with the {holderWord}… ) : holder.data !== undefined && accountReady ? ( {titleCase(holder.data)} ) : ( - The bank can't confirm a name here, so check this number carefully. + The {holderWord} can't confirm a name here, so check this number carefully. )} diff --git a/apps/dapp/components/intents/cashout-currency-question.tsx b/apps/dapp/components/intents/cashout-currency-question.tsx index e42460b9..2903a709 100644 --- a/apps/dapp/components/intents/cashout-currency-question.tsx +++ b/apps/dapp/components/intents/cashout-currency-question.tsx @@ -2,12 +2,16 @@ import { cn } from '@intent/ui' +import type { ChainSequenceRequest } from '../../lib/chains/sequence' import { CURRENCY_QUESTION, type CashoutCurrency } from '../../lib/weave/cashout-view' +import { currencyFlag } from '../../lib/weave/currency-flag' import { agentBubble, userBubble } from './bubble' export interface CashoutQuestionState { amount?: string choices: CashoutCurrency[] + /** A swap waiting on this answer, on the chain it was asked on: answering starts its race, not the form. */ + sequence?: { request: ChainSequenceRequest; chain: string } /** What the person answered. Set once, and the question stays in the thread as asked. */ answer?: string /** An answer that couldn't be used, and what was said back. */ @@ -40,18 +44,31 @@ export function CashoutCurrencyQuestion({ ) : null} {open ? (
- {question.choices.map((choice) => ( - - ))} + {question.choices.map((choice) => { + const flag = currencyFlag(choice.code) + return ( + + ) + })}
) : (
diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx index 9011730c..b3991caa 100644 --- a/apps/dapp/components/intents/chain-action-confirm.tsx +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -4,10 +4,51 @@ import { Button, Card } from '@intent/ui' import { ExternalLink, Loader2 } from 'lucide-react' import type { ChainAction } from '../../hooks/use-chain-action' +import { stepErrorMessage } from '../../lib/agents/chain-race' +import { limitSettledNote } from '../../lib/chains/orders-view' +import { useChain } from '../../providers/chain-provider' +import type { PreparedAction } from '../../lib/chains/types' -/** Review, sign and result for an action on Solana, Sui or NEAR. */ -export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element { - const { phase, prepared, error, hash, explorerUrl } = action +function cardLine({ kind, pay, venueName }: PreparedAction): string { + const what = `${pay.amount} ${pay.symbol}` + switch (kind) { + case 'send': + return `This moves ${what} from your wallet` + case 'withdraw': + case 'unstake': + return `You take ${what} out of ${venueName}` + case 'borrow': + return `You borrow ${what} from ${venueName}, and owe it back with interest` + case 'repay': + return `You repay ${what} to ${venueName}` + case 'stream': + return `This commits ${what} from your wallet to a stream on ${venueName}` + case 'invoice': + return `This bills ${what} on ${venueName}, and moves nothing from your wallet` + case 'claim': + return `You collect ${what} from ${venueName}` + case 'cancel': + return `You end this stream on ${venueName}, and get back ${what} that hasn't streamed` + default: + return `You pay ${what} on ${venueName}` + } +} + +/** Review, sign and result for an action on a chain other than Stellar. */ +export function ChainActionConfirm({ + action, + sequenceSettled = false, + typedStep, +}: { + action: ChainAction + /** This card is step 2, after a swap that was signed and settled. */ + sequenceSettled?: boolean + /** That step as a request of its own, for when it can't go on from here. */ + typedStep?: string | undefined +}): JSX.Element { + const { phase, prepared, hash, explorerUrl, earlier } = action + const { slug } = useChain() + const error = stepErrorMessage(action.error, sequenceSettled, typedStep) const busy = phase === 'signing' || phase === 'submitting' return ( @@ -15,7 +56,9 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele {phase === 'building' ? (

) : null} @@ -48,9 +91,9 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele {prepared !== undefined && phase !== 'failed' && phase !== 'pending' ? ( <>
- {prepared.summary} + {prepared.summary} - You pay {prepared.pay.amount} {prepared.pay.symbol} on {prepared.venueName} + {cardLine(prepared)} {prepared.receive !== undefined ? `, and receive ${prepared.receive.approximate ? 'about ' : ''}${prepared.receive.amount} ${prepared.receive.symbol}` : ''} @@ -74,7 +117,52 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele ) : null} - {phase === 'settled' ? ( + {phase === 'review' && earlier !== undefined ? ( +

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

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

+ {error} +

+ ) : null} + + {phase === 'settled' && action.canContinue ? ( +
+

+ Done. One more signature finishes this. +

+
+ +
+
+ ) : null} + + {phase === 'settled' && + !action.canContinue && + prepared.kind === 'limit' && + action.source?.kind === 'request' ? ( +

+ {limitSettledNote(slug)} +

+ ) : null} + + {phase === 'settled' && action.canContinue ? null : phase === 'settled' ? (

Sent {explorerUrl !== undefined ? ( diff --git a/apps/dapp/components/intents/chain-orders-card.tsx b/apps/dapp/components/intents/chain-orders-card.tsx new file mode 100644 index 00000000..5a453a46 --- /dev/null +++ b/apps/dapp/components/intents/chain-orders-card.tsx @@ -0,0 +1,130 @@ +'use client' + +import { Button, Card } from '@intent/ui' +import { Loader2 } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' + +import type { ChainAction } from '../../hooks/use-chain-action' +import { useWallet } from '../../hooks/use-wallet' +import type { RestingOrder } from '../../lib/chains/limit' +import { exitsFor, freshExit, GONE, orderLine, type OrderExit } from '../../lib/chains/orders-view' +import { useChain } from '../../providers/chain-provider' + +const UNAVAILABLE = 'Your orders could not be read just now. Nothing was signed or sent.' + +/** + * The account's resting limit orders, read from the venue when this opens and again after an exit + * settles, and never kept. Each press reads the order once more before anything is built. + */ +export function ChainOrdersCard({ + action, + asked, +}: { + action: ChainAction + asked: string +}): JSX.Element { + const { slug } = useChain() + const { address } = useWallet() + const [orders, setOrders] = useState(null) + const [error, setError] = useState(undefined) + const [note, setNote] = useState(undefined) + const settledHash = action.phase === 'settled' ? action.hash : undefined + + const read = useCallback(async (): Promise => { + if (address === undefined) return undefined + try { + const res = await fetch(`/api/chain/${slug}/orders?account=${encodeURIComponent(address)}`) + const body = (await res.json()) as { orders?: RestingOrder[] } + return res.ok && Array.isArray(body.orders) ? body.orders : undefined + } catch { + return undefined + } + }, [address, slug]) + + useEffect(() => { + let current = true + void read().then((found) => { + if (!current) return + if (found === undefined) { + setError(address === undefined ? 'Connect a wallet to see your orders.' : UNAVAILABLE) + return + } + setError(undefined) + setOrders(found) + }) + return () => { + current = false + } + }, [read, address, settledHash]) + + async function press(listed: RestingOrder, wanted: OrderExit): Promise { + setNote(undefined) + const fresh = await read() + if (fresh === undefined) { + setNote(UNAVAILABLE) + return + } + setOrders(fresh) + const now = fresh.find((o) => o.id === listed.id) + const exit = freshExit(listed, now, wanted) + if (exit === 'gone') { + setNote(GONE) + return + } + action.prepareExit(listed.id, exit, asked) + } + + const busy = + action.phase === 'building' || action.phase === 'signing' || action.phase === 'submitting' + + return ( + + Your limit orders + {orders === null && error === undefined ? ( +

+

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

+ {error} +

+ ) : null} + {orders !== null && orders.length === 0 ? ( +

You have no resting limit orders here.

+ ) : null} + {orders !== null && orders.length > 0 ? ( +
    + {orders.map((order) => ( +
  • + {orderLine(order)} + via {order.venueId} + {order.note !== undefined ? ( + {order.note} + ) : null} +
    + {exitsFor(order).map((exit) => ( + + ))} +
    +
  • + ))} +
+ ) : null} + {note !== undefined ? ( +

+ {note} +

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

{steps.swapLine}

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

+

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

+ {deliveredLine(delivered, sequence)} +

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

+ {step.note} +

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

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

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

+ {error} +

+ ) : null} +
+
+ ) +} diff --git a/apps/dapp/components/intents/chat-host.tsx b/apps/dapp/components/intents/chat-host.tsx new file mode 100644 index 00000000..e74ad6be --- /dev/null +++ b/apps/dapp/components/intents/chat-host.tsx @@ -0,0 +1,74 @@ +'use client' + +import { cn } from '@intent/ui' +import { usePathname, useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useRef, type ReactNode } from 'react' + +import { BANK_SEARCH_LABEL, showChatOnPath } from '../../lib/chat/live-session' +import { intentIntro } from '../../lib/intent-intro-copy' +import { useChain } from '../../providers/chain-provider' +import { IntentChat } from './intent-chat' + +/** A link that opens a rule still works when the chat is already mounted. */ +function ChatFromLink({ showing }: { showing: boolean }): JSX.Element { + const rule = useSearchParams().get('rule') + return ( + + ) +} + +/** + * Keeps the chat mounted while the person is on another page, so a race, a card + * or a payment being followed carries on and is there when they come back. + * + * Mounted on the first visit to the chat, so someone who never opens it never + * polls for it. The page itself renders nothing; what stands in for it while it + * loads is hidden too, or the spinner would sit under the chat. + */ +export function ChatHost({ children }: { children: ReactNode }): JSX.Element { + const pathname = usePathname() + const { segment, slug } = useChain() + const showing = showChatOnPath(pathname, segment) + const opened = useRef(false) + if (showing) opened.current = true + const hostRef = useRef(null) + + useEffect(() => { + if (showing) { + // Following a link opens at the top. The page's own scroll-to-top has nothing to find here. + hostRef.current?.scrollIntoView({ block: 'start' }) + return + } + // The bank list is drawn on the body, which `hidden` cannot reach. Escape is its own way out. + document + .querySelector(`input[aria-label="${BANK_SEARCH_LABEL}"]`) + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }, [showing]) + + const isNew = pathname === `/${segment}/intents/new` + + return ( + <> + {opened.current ? ( + + ) : null} +
{children}
+ + ) +} diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx index fb92b356..652b2c63 100644 --- a/apps/dapp/components/intents/competition-panel.tsx +++ b/apps/dapp/components/intents/competition-panel.tsx @@ -2,10 +2,27 @@ import { cn } from '@intent/ui' import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useRef, type ReactNode } from 'react' import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react' import type { CompetitionState } from '../../lib/agents/competition' +import { + panelErrorMessage, + chainRecommendationLine, + recommendationLine, + singleRouteNote, + unansweredLine, +} from '../../lib/agents/chain-race' import { describePlan } from '../../lib/agents/competition' +import { restAlternativeLine } from '../../lib/agents/rest-option' +import { + ALL_HELD_LINE, + allHeld, + showTakes, + talkPill, + talkSpeaker, + waitingAgents, +} from '../../lib/agents/talk-lines' import { AgentAvatar } from '../agents/agent-avatar' import { agentBubble, systemPill } from './bubble' @@ -79,11 +96,53 @@ function failureText(code: string): string { } } +/** + * One agent speaking in the thread: the same name line, avatar and bubble as its + * final card, without the figures or the Review button. + */ +function ThreadMessage({ + seed, + name, + speaker, + model, + children, +}: { + seed: string + name: string + speaker: string + model?: string | undefined + children: ReactNode +}): JSX.Element { + return ( + +
+ {speaker} + {shortModel(model) !== '' ? ( + · {shortModel(model)} + ) : null} +
+
+ +
{children}
+
+
+ ) +} + export function CompetitionPanel({ state, onExecute, executingKey, locked = false, + chainName, + directRoute = false, + signed = false, + reviewable, }: { state: CompetitionState onExecute: (key: string) => void @@ -95,9 +154,32 @@ export function CompetitionPanel({ * disabling the buttons on the first pick made it one. */ locked?: boolean + /** The chain's name, set off Stellar so a single route can be explained. */ + chainName?: string + /** The direct route is being offered instead, so the error must not say agents can't act here. */ + directRoute?: boolean + /** Something may have been signed, so "nothing was signed" must not be said. */ + signed?: boolean + /** Whether an agent's card may offer Review. Every card may when absent. */ + reviewable?: (key: string) => boolean }): JSX.Element | null { const { proposals, revealed, agents, phase, winner, error } = state const unanimous = state.unanimous === true + const thread = state.thread ?? [] + const threadLength = thread.length + const threadEnd = useRef(null) + const racing = phase === 'competing' + const shownCards = Object.values(revealed).filter(Boolean).length + const followed = useRef(false) + if (racing) followed.current = true + + // The talk and the cards after it grow below the fold while someone watches, + // so they stay in view. A reopened conversation never ran here, so it isn't moved. + useEffect(() => { + if (threadLength === 0 || !followed.current) return + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches + threadEnd.current?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'nearest' }) + }, [threadLength, shownCards, phase]) // Nothing to show until something is running. The panel used to paint // "Broadcasting…" and four "thinking…" placeholders whenever the phase was @@ -106,6 +188,10 @@ export function CompetitionPanel({ // race that was not happening and never ended. if (phase === 'idle') return null + // A refusal of the action itself is shown by the chat, as written, in the + // action card; saying "agents are not online" over it would be wrong. + if (error?.code === 'action_refused') return null + if (error !== undefined) { return (
@@ -113,12 +199,14 @@ export function CompetitionPanel({

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

-

{error.message}

+

{panelErrorMessage(error.message, signed)}

) @@ -130,12 +218,93 @@ export function CompetitionPanel({ // that answered; a row of failures says nothing the user can act on. const answered = (key: string): boolean => proposals[key]?.failed === undefined const revealedAgents = agents.filter((a) => revealed[a.key] && answered(a.key)) - const pendingAgents = decided ? [] : agents.filter((a) => !revealed[a.key] && answered(a.key)) + const waiting = waitingAgents( + agents.map((a) => a.key), + thread, + (key) => revealed[key] === true || !answered(key) + ) + const pendingAgents = decided + ? [] + : thread.length > 0 + ? agents.filter((a) => waiting.includes(a.key)) + : agents.filter((a) => !revealed[a.key] && answered(a.key)) + const nameOf = (key: string): string => agents.find((a) => a.key === key)?.name ?? key + const modelOf = (key: string): string | undefined => agents.find((a) => a.key === key)?.model + const everyoneHeld = allHeld(Object.values(proposals)) const anyAnswered = Object.values(proposals).some((p) => p.failed === undefined) + // No card for one, but a line says who left, so a short race doesn't look like the whole field. + const unanswered = unansweredLine( + agents.map((a) => ({ name: a.name, failed: proposals[a.key]?.failed })) + ) + + const restAlternative = restAlternativeLine( + Object.values(proposals).filter((p) => p.failed === undefined) + ) + + const routeNote = + chainName !== undefined ? singleRouteNote(state.routeCount, chainName) : undefined return (
- {revealedAgents.length === 0 && !decided ? ( + {routeNote !== undefined ?
{routeNote}
: null} + {showTakes(thread, phase) + ? thread.map((item, i) => { + if (item.type === 'take') { + const take = state.takes?.[item.agent] + if (take === undefined) return null + const tag = describePlan(take) + return ( + + {tag !== '' ? ( +

+ {tag} +

+ ) : null} +

{take.reasoning}

+
+ ) + } + const { line } = item + const pill = talkPill(line, nameOf) + if (pill !== undefined) { + return ( + + {line.kind === 'reading' && racing ? ( + + ) : null} + {pill} + + ) + } + const speaker = talkSpeaker(line, nameOf) + if (speaker === undefined || (line.kind !== 'asked' && line.kind !== 'answered')) { + return null + } + return ( + +

{line.text}

+
+ ) + }) + : null} + {revealedAgents.length === 0 && !decided && thread.length === 0 ? (
Broadcasting to the agent network… @@ -151,7 +320,9 @@ export function CompetitionPanel({ // that makes the same name look favoured race after race. const isWinner = decided && !unanimous && winner === agent.key const isExecuting = executingKey === agent.key - const dimmed = decided && !unanimous && !isWinner && !isExecuting + const dimmed = decided && !unanimous && !isWinner && !isExecuting && !everyoneHeld + const held = proposal?.held === true + const canOpen = !held && (reviewable === undefined || reviewable(agent.key)) const tag = proposal !== undefined ? describePlan(proposal) : '' return ( @@ -235,44 +406,51 @@ export function CompetitionPanel({ ) : null} -
- {/* Measured from the route, not reported by the agent: + {held ? null : ( +
+ {/* Measured from the route, not reported by the agent: what a unit cost, and how that compares with the oracle's fair value. */} -
- - {money(proposal?.avgPriceUsd ?? 0)} avg - - - {[ - vsOracle(proposal?.vsOraclePct), - proposal?.executionMode === 'rest' && - proposal.horizonMinutes !== undefined && - proposal.horizonMinutes > 0 - ? `over ${proposal.horizonMinutes}m` - : '', - ] - .filter((s) => s !== '') - .join(' · ')} - -
- ) : null} - {isExecuting ? 'Selected' : 'Review'} - -
+
+ )}
)}
@@ -311,12 +489,29 @@ 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. */} - {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.`} + by a draw the agents cannot influence. A supply or a stake is + ranked on its rate instead, and the line says that rate. */} + {chainName !== undefined + ? chainRecommendationLine(proposals[winner] ?? {}, { + agreeing: unanimous + ? Object.values(proposals).filter( + (p) => p.failed === undefined && p.held !== true + ).length + : 0, + }) + : unanimous + ? `All ${Object.values(proposals).filter((p) => p.failed === undefined && p.held !== true).length} agents agree: ${describePlan(proposals[winner] ?? {})}. Pick any to execute.` + : recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)} ) : null} + + {decided && chainName !== undefined && restAlternative !== undefined ? ( +
{restAlternative}
+ ) : null} + {decided && everyoneHeld ?
{ALL_HELD_LINE}
: null} + + {decided && unanswered !== undefined ?
{unanswered}
: null} + {thread.length > 0 ?
: null}
) } diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx index 629b1cd4..b3e38e76 100644 --- a/apps/dapp/components/intents/intent-chat.tsx +++ b/apps/dapp/components/intents/intent-chat.tsx @@ -19,11 +19,38 @@ import { unclearMessage, } from '../../lib/chat/orchestrate-copy' import { rememberExchange } from '../../lib/chat/dialogue' +import { + appendLiveTurn, + cardIsStale, + chainOutcomeSentence, + clearLiveSession, + FINISH_SIGNATURE_NOTICE, + liveSessionStorage, + loadLiveSession, + ownedTurns, + ownerChange, + recordableReply, + WALLET_SWITCHED_NOTICE, + type LiveTurn, +} from '../../lib/chat/live-session' import { fixVerbTypo } from '../../lib/chat/typos' import { StrategyEditor, type StrategyEdits } from './strategy-editor' import { DEFAULT_SLIPPAGE_BPS } from '../../lib/swap/build-tx' import { ACKNOWLEDGED } from '../../lib/chat/clarify-flow' import type { ClarifyTurn } from '../../lib/clarify/clarify' +import { + chainReviewClosed, + raceClosed, + stepErrorMessage, + chainSigning, + isChainRoute, + canReview, + needsDirectRoute, + reviewChainRoute, + routeForSwap, + selectionHolds, + showDirectNote, +} from '../../lib/agents/chain-race' import { agentsFromProposals } from '../../lib/agents/competition' import type { CreateIntentInput } from '@intent/types' @@ -52,16 +79,44 @@ import { StandingInboxPanel } from './standing-inbox-panel' import { useStandingRules } from '../../hooks/use-standing-rules' import { useStandingInbox } from '../../hooks/use-standing-inbox' import { ensureSession } from '../../lib/api/auth' +import { balanceReplyDecision, chainBalanceReply } from '../../lib/chains/balance-reply' import { chainCapabilities } from '../../lib/chains/capabilities' -import { chainHelp } from '../../lib/chains/explain' -import { looksLikeChainAction, parseChainAction } from '../../lib/chains/parse-action' +import { sizeInUnits } from '../../lib/chains/dollar-amount' +import { chainHelp, helpForUnread } from '../../lib/chains/explain' +import { racesFirst } from '../../lib/chains/parse-action' +import { asksForPositions, readPositionsReply } from '../../lib/chains/positions-reply' +import { + cashoutStarts, + chainRouteFromReading, + messageStartsAction, + readChainMessage, +} from '../../lib/chains/parse-sequence' +import { followOnRefusal, type ChainSequenceRequest } from '../../lib/chains/sequence' +import { + CASHOUT_UNDER_WAY, + cashoutSequence, + currencyStep, + endedNote, + keepsRun, + questionAnswerFrom, + questionChainRefusal, + sequenceOutcome, + stepOnItsOwn, + readFollowOnRefusal, + cashoutSaid, +} from '../../lib/chains/sequence-flow' +import type { ChainActionRequest, ChainWithActions, WalletBalances } from '../../lib/chains/types' +import { QUOTE_EXPIRED } from '../../lib/chains/signing-flow' import { useChainAction } from '../../hooks/use-chain-action' +import { useChainSequence } from '../../hooks/use-chain-sequence' import { ChainActionConfirm } from './chain-action-confirm' +import { ChainSequenceSteps } from './chain-sequence-steps' import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout' import { CashoutCard } from './cashout-card' import { CashoutCurrencyQuestion, type CashoutQuestionState } from './cashout-currency-question' import { cashoutChoices, + CURRENCY_QUESTION, NO_CASHOUTS, unsupportedCurrencyReply, unsupportedTokenReply, @@ -114,6 +169,10 @@ import type { WithdrawLimits } from '../../lib/offramp/sep24' import { estimatedReceiveOf } from '../../lib/offramp/labels' import { useSupply } from '../../hooks/use-supply' import { SupplyConfirm } from './supply-confirm' +import { useBorrow } from '../../hooks/use-borrow' +import { useWithdraw } from '../../hooks/use-withdraw' +import { BorrowConfirm } from './borrow-confirm' +import { WithdrawConfirm } from './withdraw-confirm' import { usePerp } from '../../hooks/use-perp' import { PerpConfirm } from './perp-confirm' import { parsePerpIntent } from '../../lib/parse-perp' @@ -122,14 +181,16 @@ import { tradeableSymbols, type VerifiedAsset, } from '../../lib/swap/asset-registry' -import { blendXlm } from '../../lib/lend/reserves' +import { parseStellarExit, whereReply, type StellarExit } from '../../lib/lend/parse-exit' +import type { BlendPositions } from '../../lib/lend/position' +import { suppliableReserve } from '../../lib/lend/reserves' import { lendingVenueName } from '../../lib/lend/venues' +import { ChainOrdersCard } from './chain-orders-card' import { IntentConfirm, type UnderstoodIntent } from './intent-confirm' import { resolveAsset, toBaseUnits } from '../../lib/swap/assets' import { toPriceFraction } from '../../lib/swap/limit-price' import { LimitConfirm } from './limit-confirm' import { OpenOrders } from './open-orders' -import { TradeCapNote } from '../layout/trade-cap' import { ComposerInput } from './composer-input' import { NativeCoinTicker } from './native-coin-ticker' import { PriceTicker } from './price-ticker' @@ -180,7 +241,42 @@ function ChatIdentity(): JSX.Element { ) } -export function IntentChat(): JSX.Element { +/** What was said before this page was opened: words and a link, nothing to press or sign. */ +function SavedTurns({ turns }: { turns: LiveTurn[] }): JSX.Element { + return ( + <> + {turns.map((turn, i) => ( +
+
+ {turn.text} + {turn.explorerUrl !== undefined ? ( + + View transaction + + ) : null} +
+
+ ))} + + ) +} + +export function IntentChat({ + showing, + linkedRule, +}: { + /** False while the person is on another page and this is kept mounted behind it. */ + showing: boolean + linkedRule?: string +}): JSX.Element { const createIntent = useCreateIntent() const cancelIntent = useCancelIntent() const settleIntent = useSettleIntent() @@ -188,6 +284,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) @@ -243,6 +347,10 @@ export function IntentChat(): JSX.Element { // Messages that were answered rather than run, with the replies given. Sent // with the next message so the model answers in the flow of the conversation. const dialogue = useRef([]) + // Where the chat is now, read after a slow answer to tell whether it is still wanted. + const latest = useRef({ chain: '', account: undefined as string | undefined, turn: 0 }) + // Clarify calls still waiting on an answer. The wait is over only when none are. + const clarifyRuns = useRef(0) function openInbox(): void { const items = inbox.items @@ -255,10 +363,12 @@ export function IntentChat(): JSX.Element { inbox.markSeen(items.map((i) => i.id)) } + // From the address, and read again when it changes: the chat stays mounted + // across pages, so a link can arrive long after it first rendered. useEffect(() => { - const id = new URLSearchParams(window.location.search).get('rule') - if (id !== null && id !== '') setLinkedRuleId(id) - }, []) + openedFromLink.current = false + setLinkedRuleId(linkedRule !== undefined && linkedRule !== '' ? linkedRule : null) + }, [linkedRule]) // Opens on the linked rule once the inbox has answered, and only once: a // user who closes it should not have it reopen on the next poll. @@ -284,6 +394,8 @@ export function IntentChat(): JSX.Element { // to catch a misread instruction is before the agents spend a minute arguing // about a trade that was never the one asked for. const [pending, setPending] = useState(null) + /** The message that asked for the resting limit orders, while their list is on screen. */ + const [ordersShown, setOrdersShown] = useState(null) const [parsing, setParsing] = useState(false) // The conversation around the open intent. `thread` holds the turns that came // before it, kept in view so the chat reads as one; `notes` are things the @@ -303,8 +415,61 @@ export function IntentChat(): JSX.Element { const slippageEdit = strategy.slippageBps !== undefined ? { slippageBps: strategy.slippageBps } : {} - const { slug, descriptor } = useChain() - const { address, isConnected, balance: nativeBalance, balanceSymbol } = useWallet() + const { slug, segment, descriptor } = useChain() + const { address, isConnected } = useWallet() + latest.current.chain = slug + latest.current.account = address + + // What was said here before a reload. Read while no thread is open, which is + // also what keeps a wallet's own words from showing twice, and shown under the + // account it was read for only. Writing it never touches saveTurn. + const [savedTurns, setSavedTurns] = useState<{ account: string; turns: LiveTurn[] } | null>(null) + const transcriptOwner = isConnected ? address : undefined + // Whose chat is on screen, which is who it is recorded for. Differs from the + // connected wallet only while a switch waits on an open signature. + const chatOwner = useRef(transcriptOwner) + const transcript = restored === null ? ownedTurns(savedTurns, transcriptOwner) : [] + // Earlier turns and notes kept for the agents, already shown among the saved turns. + const [inSaved, setInSaved] = useState({ thread: 0, notes: 0 }) + useEffect(() => { + setSavedTurns( + transcriptOwner !== undefined && message === null && thread.length === 0 + ? { + account: transcriptOwner, + turns: loadLiveSession(liveSessionStorage(), segment, transcriptOwner), + } + : null + ) + // Only a new wallet or chain reads again; a thread opening must not. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segment, transcriptOwner]) + + function recordLive( + role: LiveTurn['role'], + text: string, + explorerUrl?: string, + account: string | undefined = chatOwner.current + ): void { + if (account === undefined) return + appendLiveTurn(liveSessionStorage(), segment, account, { + role, + text, + at: Date.now(), + ...(explorerUrl !== undefined ? { explorerUrl } : {}), + }) + } + + // One line per transaction, however many times its state is rendered or reported. + const recordedHashes = useRef(new Set()) + function recordSettled( + text: string, + hash: string | undefined, + explorerUrl: string | undefined + ): void { + if (hash === undefined || recordedHashes.current.has(hash)) return + recordedHashes.current.add(hash) + recordLive('agent', text, explorerUrl) + } // Checked once the wallet that owns it is known — historyOwner, which // pendingCashOut reads, is set from this same address elsewhere — so a @@ -405,8 +570,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 @@ -422,6 +588,9 @@ export function IntentChat(): JSX.Element { phase: 'decided' as const, secondsLeft: 0, winner: restored.winner, + ...(restored.talk !== undefined + ? { thread: restored.talk.thread, takes: restored.talk.takes } + : {}), } : liveCompetition @@ -440,7 +609,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) @@ -455,9 +626,76 @@ export function IntentChat(): JSX.Element { // Supplying an asset already held. A separate shape from a sequence: there is // no earlier step whose output has to be carried. const supply = useSupply() + // Money coming back out, typed rather than pressed. Their own hooks: these never run beside a trade. + const exitWithdraw = useWithdraw() + const exitRepay = useBorrow() const chainAction = useChainAction() + // A new race gets a clean slate when the chain changes. + useEffect(() => { + clearChainRace() + chainAction.release() + chainAction.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) + // Selected is shown only while a build or its aftermath is live; a failed + // build must not leave an agent looking chosen. + useEffect(() => { + if (chainRace !== null && !selectionHolds(chainAction.phase)) setExecutingKey(null) + }, [chainRace, chainAction.phase]) + const chainConfirmRef = useRef(null) + const chainAwaiting = chainAction.phase === 'review' + useEffect(() => { + if (!chainAwaiting) return + chainConfirmRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [chainAwaiting]) + // The race ended with nothing to review: build the direct route once, so the + // chain keeps working when the agents are offline. A refusal of the action + // is shown as written by the card and is not retried. + const fellBackRef = useRef(null) + // The race the hook has actually begun. On the render where a new race is + // asked for, the hook still holds the last one's ending, which must not be + // mistaken for this race's. + const startedRef = useRef(null) + const raceEnded = needsDirectRoute({ + phase: live.phase, + winner: live.winner, + anyAnswered: Object.values(live.proposals).some((p) => p.failed === undefined), + ...(live.error !== undefined ? { error: live.error } : {}), + }) + useEffect(() => { + if (chainRace === null || restored !== null || fellBackRef.current === chainRace) return + // The swap this race was for has settled, so nothing more is built from it. + if (chainSequence.settled) return + if (live.phase === 'competing') startedRef.current = chainRace + if (startedRef.current !== chainRace) return + if (live.error?.code === 'action_refused') { + 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 swap and what follows it, on a chain with its own module. Step 1 is the race above. + const chainSequence = useChainSequence(chainAction, cashout) + // Read when a model's answer comes back, which may be after the run has moved on. + const chainSequenceNow = useRef(chainSequence) + chainSequenceNow.current = chainSequence + // What a new message left behind by ending a swap that had settled. + const [sequenceNote, setSequenceNote] = useState(null) + useEffect(() => { + chainSequence.clear() + setSequenceNote(null) + // A question asked on the last chain can't start anything on this one. + setCashoutQuestion(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) // A leveraged position on Noether. Its own shape again: no route, no // competition, and a sign-in with the venue before anything is built. const perp = usePerp() @@ -671,6 +909,9 @@ export function IntentChat(): JSX.Element { proposals: liveCompetition.proposals, winner: liveCompetition.winner, routesByAgent: live.routesByAgent, + ...((live.thread ?? []).some((i) => i.type === 'line' && i.line.kind !== 'turn') + ? { talk: { thread: live.thread ?? [], takes: live.takes ?? {} } } + : {}), }) setTurns(loadTurns(slug)) // Keyed on the decision, not on the proposals object, which is rebuilt on @@ -678,6 +919,122 @@ export function IntentChat(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [decided, turnId, message, slug]) + // The transcript, as things are said and settle. Words and links only: a label + // or a card's figures come from what is signed, so none of them are kept. + useEffect(() => { + if ( + affordError !== null && + message !== null && + restored === null && + recordableReply(affordError) + ) { + recordLive('agent', affordError) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [affordError]) + + // A chain race leaves one sentence, below, and none of its agents' reasoning. + const raceWinner = liveCompetition.winner + const raceReply = + restored !== null || chainRace !== null + ? undefined + : decided && raceWinner !== null + ? liveCompetition.proposals[raceWinner]?.reasoning + : live.error?.message + useEffect(() => { + if (raceReply !== undefined && raceReply !== '' && message !== null) { + recordLive('agent', raceReply) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raceReply]) + + const typedStep = + chainSequence.sequence === null ? undefined : stepOnItsOwn(chainSequence.sequence) + const chainOutcome = chainOutcomeSentence({ + phase: chainAction.phase, + venueName: chainAction.prepared?.venueName, + error: stepErrorMessage(chainAction.error, chainSequence.settled, typedStep), + }) + useEffect(() => { + if (chainOutcome === undefined || message === null) return + // To the wallet that signed it, should another have connected since. + recordLive( + 'agent', + chainOutcome, + chainAction.phase === 'settled' ? chainAction.explorerUrl : undefined, + chainAction.account ?? chatOwner.current + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainOutcome]) + + const sequenceLine = sequenceOutcome( + chainSequence.stage, + chainSequence.delivered, + chainSequence.error, + chainSequence.sequence ?? undefined + ) + // Leaving the form brings `ready` back, and what arrived was said the first time. + const deliveredRecordedFor = useRef(null) + useEffect(() => { + if (sequenceLine === undefined) return + if (chainSequence.stage === 'ready') { + if (deliveredRecordedFor.current === chainSequence.sequence) return + deliveredRecordedFor.current = chainSequence.sequence + } + recordLive( + 'agent', + sequenceLine, + undefined, + chainSequence.account ?? chainAction.account ?? chatOwner.current + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequenceLine]) + + useEffect(() => { + recordSettled('The swap settled.', settledHash, swap.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settledHash]) + useEffect(() => { + const total = sequence.steps.length + sequence.steps.forEach((step, i) => { + recordSettled( + total === 1 ? 'The transaction settled.' : `Step ${i + 1} of ${total} settled.`, + step.hash, + step.explorerUrl + ) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequenceSteps]) + const supplyHash = supply.phase === 'settled' ? supply.hash : undefined + useEffect(() => { + recordSettled('The supply settled.', supplyHash, supply.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [supplyHash]) + const exitWithdrawHash = exitWithdraw.phase === 'settled' ? exitWithdraw.hash : undefined + useEffect(() => { + recordSettled('The withdrawal settled.', exitWithdrawHash, exitWithdraw.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exitWithdrawHash]) + const exitRepayHash = exitRepay.phase === 'settled' ? exitRepay.hash : undefined + useEffect(() => { + recordSettled('The repayment settled.', exitRepayHash, exitRepay.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exitRepayHash]) + const perpHash = perp.phase === 'settled' ? perp.hash : undefined + useEffect(() => { + recordSettled('The position opened.', perpHash, perp.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [perpHash]) + useEffect(() => { + recordSettled('The cash-out payment was sent.', cashout.hash, cashout.explorerUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cashout.hash]) + + /** A settled Stellar transaction known only by its hash. */ + function recordStellarTx(text: string, hash: string | undefined): void { + recordSettled(text, hash, `${stellarNetwork.blockExplorerUrl}/tx/${hash}`) + } + // The USDC the swap delivered is read from the account, so the balance is // refreshed the moment the swap settles. const swapSettled = swap.phase === 'settled' @@ -725,7 +1082,104 @@ export function IntentChat(): JSX.Element { swap.phase === 'signing' || swap.phase === 'submitting' || sequence.phase === 'signing' || - sequence.phase === 'submitting' + sequence.phase === 'submitting' || + exitWithdraw.phase === 'signing' || + exitWithdraw.phase === 'submitting' || + exitRepay.phase === 'signing' || + exitRepay.phase === 'submitting' || + chainSigning(chainAction.phase) + + // Declared after the effects that record, so a transaction settling in the same + // render as a switch is written to the wallet that signed it, before the reset. + // A sequence with a step settled and the next unsigned counts as open: resetting + // would lose the only way to finish it. So does a chain swap still being measured. + // Off Stellar the wait ends when the signature does, or the measure: the reset below + // then ends the run, whether the swap just settled or reached `ready`. The new wallet + // is offered no step 2, and the outcome goes to the transcript of the wallet that signed. + const signatureOpen = + executionInFlight || + cashout.locked || + chainSequence.stage === 'measuring' || + (sequence.phase !== 'settled' && sequence.steps.some((step) => step.hash !== undefined)) + useEffect(() => { + const change = ownerChange(chatOwner.current, transcriptOwner, signatureOpen) + if (change === 'defer') { + setAffordError(WALLET_SWITCHED_NOTICE) + return + } + if (change === 'keep') { + if (transcriptOwner !== undefined) chatOwner.current = transcriptOwner + setAffordError((e) => (e === WALLET_SWITCHED_NOTICE ? null : e)) + return + } + leaveSequence() + clearOpenChat() + // None of what follows is cleared by New chat, and all of it is the last wallet's. + dialogue.current = [] + setPending(null) + supply.reset() + exitWithdraw.reset() + exitRepay.reset() + perp.reset() + planExec.reset() + limit.reset() + chainAction.reset() + setHistoryOpen(false) + setRulesOpen(false) + setInboxOpen(false) + chatOwner.current = transcriptOwner + setSavedTurns( + transcriptOwner === undefined + ? null + : { + account: transcriptOwner, + turns: loadLiveSession(liveSessionStorage(), segment, transcriptOwner), + } + ) + // Runs on a new wallet, and again when the signature it was waiting on ends. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transcriptOwner, signatureOpen]) + + // Coming back to a card left open. A chain's card or a position holds what was + // built when it was shown, and not every one is refused later: NEAR's wallet sends + // for itself with no check of the deadline. A Stellar swap is rebuilt and aged by + // the server at signing, and its other envelopes lapse on the network, so neither + // is touched here. + const chainCardShownAt = useRef(0) + useEffect(() => { + chainCardShownAt.current = Date.now() + }, [chainAction.prepared]) + const perpCardShownAt = useRef(0) + useEffect(() => { + perpCardShownAt.current = Date.now() + }, [perp.xdr]) + const wasShowing = useRef(showing) + useEffect(() => { + const cameBack = showing && !wasShowing.current + wasShowing.current = showing + if (!cameBack) return + const now = Date.now() + const deadline = chainAction.prepared?.context?.['deadline'] + if ( + chainAction.phase === 'review' && + cardIsStale( + { + ...(deadline !== undefined ? { deadline } : {}), + shownAt: chainCardShownAt.current, + }, + now + ) + ) { + chainAction.refuse(QUOTE_EXPIRED, chainAction.asked ?? message ?? '') + } + if (perp.phase === 'review' && cardIsStale({ shownAt: perpCardShownAt.current }, now)) { + perp.reset() + setAffordError(QUOTE_EXPIRED) + } + // Only the moment of coming back decides this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showing]) + /** Something came of the open intent: a trade settled, or an order was placed. */ const turnExecuted = swap.phase === 'settled' || sequence.phase === 'settled' || placedId !== null @@ -762,11 +1216,20 @@ export function IntentChat(): JSX.Element { setNotes([]) } + /** Ends a chain race and what hangs off it; the direct-route note belongs to the race. */ + function clearChainRace(): void { + setChainRace(null) + setDirectFallback(false) + } + /** What a previous attempt left on screen, which a new turn must not inherit. */ function resetTurnState(): void { + clearChainRace() sequence.reset() swap.reset() supply.reset() + exitWithdraw.reset() + exitRepay.reset() perp.reset() setExecutingKey(null) setPlacedId(null) @@ -828,6 +1291,7 @@ export function IntentChat(): JSX.Element { setMessage(text) setCashOutFrom({ hash: stranded.swapHash, receive: stranded.receiveDisplay }) setStrandedCashOut(undefined) + recordLive('user', text) saveTurn({ id: newTurnId, chain: slug, @@ -860,7 +1324,7 @@ export function IntentChat(): JSX.Element { if (!continuing && message !== null && parsed !== null && restored === null && !turnExecuted) { if (classifyFollowUp(text) === 'note') { if (executionInFlight) { - setAffordError('Finish or cancel the signature first, then continue the chat.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } setNotes((n) => [...n, text]) @@ -974,6 +1438,7 @@ export function IntentChat(): JSX.Element { const mentionsBase = /(xlm|usdc)/i.test(text) if (other !== undefined && (source.kind === 'other' || !mentionsBase)) { const wanted = other.symbol + const wantedBy = latest.current.account void (async () => { let found: VerifiedAsset | undefined try { @@ -983,6 +1448,7 @@ export function IntentChat(): JSX.Element { } catch { found = undefined } + if (wantedBy !== latest.current.account) return if (found === undefined) { setAffordError( `I can't confirm ${wanted} as a verified token on Stellar, so I won't cash it out. Which token do you mean?` @@ -1067,15 +1533,23 @@ export function IntentChat(): JSX.Element { return } + // The asset named, by its own contract. This always sent XLM's, so "supply 5 USDC" signed 5 XLM + // under a card that said USDC. Refused when the pool does not lend the token held here. + const reserve = suppliableReserve(supplyOnly.asset) + if (reserve === undefined) { + setAffordError(`Blend does not take ${supplyOnly.asset} here. Nothing was signed or sent.`) + return + } + const units = supplyOnly.amount === undefined - ? (balances?.xlm ?? '0') + ? heldUnits(supplyOnly.asset) : supplyOnly.amountIsUsd === true ? (Number(supplyOnly.amount) / (price as number)).toFixed(7) : supplyOnly.amount supply.prepare({ - assetId: blendXlm(), + assetId: reserve, symbol: supplyOnly.asset, amount: toBaseUnits(units), }) @@ -1133,6 +1607,7 @@ export function IntentChat(): JSX.Element { // ways of writing one instruction it understood five, and the misses split // evenly between an unmatched marker and an unlisted verb. Widening either // list closes those and fails on the next seven. + const readFor = latest.current.account void (async () => { setParsing(true) let read: UnderstoodIntent | null = null @@ -1149,6 +1624,7 @@ export function IntentChat(): JSX.Element { body: JSON.stringify({ text, chain: slug }), }) ).json() + if (readFor !== latest.current.account) return const body = raw as { understood?: boolean action?: 'swap' | 'supply' | 'borrow' | 'repay' | 'offramp' @@ -1235,15 +1711,23 @@ export function IntentChat(): JSX.Element { return } + const reserve = suppliableReserve(symbol) + if (reserve === undefined) { + setParsed(null) + setFollowOn(null) + setAffordError(`Blend does not take ${symbol} here. Nothing was signed or sent.`) + return + } + const units = !stated - ? (balances?.xlm ?? '0') + ? heldUnits(symbol) : body.amountIsUsd === true ? ((body.amountUsd ?? 0) / (price as number)).toFixed(7) : String(body.amountUsd ?? 0) setParsed(null) setFollowOn(null) - supply.prepare({ assetId: blendXlm(), symbol, amount: toBaseUnits(units) }) + supply.prepare({ assetId: reserve, symbol, amount: toBaseUnits(units) }) return } // A token the app cannot vouch for: ask rather than guess. Nothing is run. @@ -1319,13 +1803,236 @@ export function IntentChat(): JSX.Element { })() } + /** All of an asset the wallet holds, as a display figure. */ + function heldUnits(symbol: string): string { + return (symbol === 'XLM' ? balances?.xlm : balances?.trustlines?.[symbol]?.balance) ?? '0' + } + + /** + * "Withdraw 25 XLM" and "repay 5 USDC" on Stellar. Straight to a card to sign, with no model + * in the way: see `parse-exit.ts`. The server reads the position and says if it can't be done. + */ + function startStellarExit(exit: StellarExit, text: string): void { + if (executionInFlight) { + setAffordError(FINISH_SIGNATURE_NOTICE) + return + } + archiveOpenTurn() + resetTurnState() + setClarifying(null) + setParsed(null) + setMessage(text) + setTurnId(crypto.randomUUID()) + + if (exit.kind === 'where') { + if (!isConnected || address === undefined) { + setAffordError('Connect a wallet first, so there is an account to look at.') + return + } + void fetch(`/api/lend/position?account=${encodeURIComponent(address)}`) + .then(async (res) => { + if (!res.ok) throw new Error('unread') + const body = (await res.json()) as { positions?: BlendPositions | null } + if (body.positions == null) throw new Error('unread') + setAffordError(whereReply(body.positions)) + }) + .catch(() => { + setAffordError('Could not read your Blend position. Nothing was signed or sent.') + }) + return + } + + let amount: string | undefined + try { + amount = exit.amount === undefined ? undefined : toBaseUnits(exit.amount) + } catch { + setAffordError( + 'That amount has more decimal places than Stellar keeps. Nothing was signed or sent.' + ) + return + } + const request = { symbol: exit.symbol, ...(amount !== undefined ? { amount } : {}) } + if (exit.kind === 'withdraw') exitWithdraw.prepare(request) + else exitRepay.prepare('repay', request) + } + + /** Ends a chain sequence where it stands. Its USDC stays the person's, and they are told so. */ + function leaveSequence(): void { + const run = chainSequenceNow.current + const note = endedNote(run.stage, run.delivered, run.sequence) + setSequenceNote(note ?? null) + if (note !== undefined) { + recordLive('agent', note, undefined, run.account ?? chatOwner.current) + } + run.clear() + } + + /** Step 2's press. A model's answer still on its way must not start anything under it. */ + function pressNextStep(): void { + latest.current.turn += 1 + chainSequence.runNext() + } + + /** Step 1: the same race as a plain swap, with the sequence watching its card. */ + function runSequenceSwap(request: ChainSequenceRequest, text: string): void { + // Sized here and not on the way in, so a currency question left open is priced when answered. + if (request.swap.amountIsUsd === true) { + sizeThen(request.swap, text, (swap) => runSequenceSwap({ ...request, swap }, text)) + return + } + setAffordError(null) + // A new race is the one thing that lifts the "already signed" latch. + chainAction.release() + chainAction.reset() + chainSequence.start(request, text) + setChainRace({ request: request.swap, text }) + } + + /** + * "$5 of SOL" as a count of SOL at a price read now, and says so. The two places a race or a card + * starts come through here first, so a figure in dollars can't reach one as that many tokens. + */ + function sizeThen( + request: ChainActionRequest, + text: string, + go: (sized: ChainActionRequest) => void + ): void { + setMessage(text) + setClarifying(null) + setAffordError(null) + clearChainRace() + chainAction.reset() + setClarifyBusy(true) + const { turn, account, chain } = latest.current + void sizeInUnits(slug, request).then((out) => { + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The person has moved on, and nothing must start under what they did next. + const now = latest.current + if (turn !== now.turn || account !== now.account || chain !== now.chain) return + if ('refusal' in out) { + chainAction.refuse(out.refusal, text) + return + } + go(out.request) + const { line } = out + if (line !== undefined) { + // Added to, not over, a note on what a run left behind, such as USDC still in the wallet. + setSequenceNote((shown) => (shown === null ? line : `${shown} ${line}`)) + recordLive('agent', line) + } + }) + } + + /** A plain chain action, typed or read by the orchestrator: the race, or the card for a send. */ + function startChainAction(action: ChainActionRequest, text: string): void { + if (action.amountIsUsd === true) { + sizeThen(action, text, (sized) => startChainAction(sized, text)) + return + } + // The thread only renders once there is a message, so without this the + // card below would be built and never seen. + setMessage(text) + // A reopened turn, an open Stellar intent and its saved id all end here, + // so the race is the only thing on screen and is not saved over them. + resetTurnState() + setParsed(null) + setTurnId(null) + setClarifying(null) + setAffordError(null) + // The agents race first; Review on a route is what builds it. A new + // race is the one thing that lifts the "already signed" latch. + chainAction.release() + chainAction.reset() + if (racesFirst(action)) { + setChainRace({ request: action, text }) + } else { + // Nothing to price or choose, so a stale race is cleared and the card comes straight up. + clearChainRace() + chainAction.prepare(action, text) + } + } + + /** A swap and what follows it. Whatever could not finish is refused here, before step 1. */ + function startChainSequence(request: ChainSequenceRequest, text: string): void { + const capabilities = chainCapabilities(slug) + if (capabilities === undefined) return + const { followOn } = request + setMessage(text) + resetTurnState() + setParsed(null) + setTurnId(null) + setClarifying(null) + const refusal = + (followOn.kind === 'cashout' && cashout.locked ? CASHOUT_UNDER_WAY : undefined) ?? + followOnRefusal( + slug as ChainWithActions, + descriptor.name, + followOn, + capabilities.map((c) => ({ kinds: [c.kind] })), + request.swap.tokenOut + ) + if (refusal !== undefined) { + chainAction.refuse(refusal, text) + return + } + if (followOn.kind !== 'cashout') { + if (address === undefined) { + chainAction.refuse('Connect a wallet first.', text) + return + } + // Asked of the server before step 1, since a step that can't run is no use after it. + chainAction.reset() + setClarifyBusy(true) + const { turn, account, chain } = latest.current + void readFollowOnRefusal(slug, request, address).then((refused) => { + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The person has moved on, and a swap must not start under what they did next. + const now = latest.current + if (turn !== now.turn || account !== now.account || chain !== now.chain) return + if (refused !== undefined) chainAction.refuse(refused, text) + else runSequenceSwap(request, text) + }) + return + } + // Asked of Weave before step 1, since a currency it can't pay is no use after it. + cashout.dismiss() + chainAction.reset() + setClarifyBusy(true) + const { turn, account, chain } = latest.current + void fetchCashoutCurrencies(slug).then((weave) => { + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The person has moved on, and a swap must not start under what they did next. + const now = latest.current + if (turn !== now.turn || account !== now.account || chain !== now.chain) return + const step = currencyStep(followOn.currency, cashoutChoices(slug, weave)) + if (step.kind === 'refuse') { + chainAction.refuse(step.message, text) + } else if (step.kind === 'ask') { + setCashoutQuestion({ choices: step.choices, sequence: { request, chain } }) + recordLive('agent', CURRENCY_QUESTION) + } else { + runSequenceSwap( + { ...request, followOn: { kind: 'cashout', currency: step.currency } }, + text + ) + } + }) + } + /** A cash-out read from the composer: the card, or a plain answer about what it can't do. */ function startCashout(route: Exclude, text: string): void { if (executionInFlight) { - setAffordError('Finish or cancel the signature first, then continue the chat.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } setMessage(text) + // 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) @@ -1340,6 +2047,11 @@ export function IntentChat(): JSX.Element { supply.reset() perp.reset() + if (route.kind === 'sequence') { + startChainSequence(cashoutSequence(route), text) + return + } + if (route.kind === 'ask') { if (cashout.locked) { chainAction.refuse( @@ -1351,8 +2063,11 @@ export function IntentChat(): JSX.Element { cashout.dismiss() chainAction.reset() setClarifyBusy(true) + const asked = latest.current.turn + const askedFor = latest.current.account void fetchCashoutCurrencies(slug).then((weave) => { setClarifyBusy(false) + if (askedFor !== latest.current.account) return const choices = cashoutChoices(slug, weave) const only = choices.length === 1 ? choices[0] : undefined if (choices.length === 0) { @@ -1365,6 +2080,7 @@ export function IntentChat(): JSX.Element { choices, ...(route.amount !== undefined ? { amount: route.amount } : {}), }) + if (asked === latest.current.turn) recordLive('agent', CURRENCY_QUESTION) } }) return @@ -1393,10 +2109,12 @@ export function IntentChat(): JSX.Element { cashout.dismiss() if (route.kind === 'token') { - chainAction.refuse(unsupportedTokenReply(route.token), text) + chainAction.refuse(unsupportedTokenReply(route.token, slug), text) return } + const refusedFor = latest.current.account void fetchCashoutCurrencies(slug).then((currencies) => { + if (refusedFor !== latest.current.account) return chainAction.refuse(unsupportedCurrencyReply(route.currency, currencies), text) }) } @@ -1430,10 +2148,38 @@ export function IntentChat(): JSX.Element { }) } + /** A currency named in answer to the question: used if offered, and said back if not. */ + function answerNamedCurrency(named: string, text: string): void { + if (cashoutQuestion === null) return + const offered = cashoutQuestion.choices + if (offered.some((choice) => choice.code === named)) { + answerCashoutQuestion(named, text) + } else { + const reply = unsupportedCurrencyReply(named, offered) + setCashoutQuestion({ ...cashoutQuestion, retry: { said: text, reply } }) + recordLive('agent', reply) + } + } + function answerCashoutQuestion(currency: string, said: string): void { const asked = cashoutQuestion if (asked === null) return setCashoutQuestion({ ...asked, answer: said }) + if (asked.sequence !== undefined) { + const elsewhere = questionChainRefusal(asked.sequence.chain, slug) + if (elsewhere !== undefined) { + chainAction.refuse(elsewhere, message ?? said) + return + } + runSequenceSwap( + { + ...asked.sequence.request, + followOn: { kind: 'cashout', currency: currency as CashoutCurrencyCode }, + }, + message ?? said + ) + return + } payOutIn(currency, asked.amount, message ?? said) } @@ -1447,30 +2193,65 @@ export function IntentChat(): JSX.Element { * own words run, so asking never makes the chat less capable than before. */ function handleSubmit(typed: string): void { - // A misspelled verb is corrected before anything reads the sentence. - const text = fixVerbTypo(typed) + latest.current.turn += 1 + // A misspelled verb is corrected before anything reads the sentence. "Tell me my positions" + // has none, and would become "sell me". + const text = asksForPositions(typed) ? typed : fixVerbTypo(typed) + + // A chain signature is with the wallet: a new message must not close its card. + if (chainSigning(chainAction.phase)) { + setAffordError(FINISH_SIGNATURE_NOTICE) + return + } + setOrdersShown(null) const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet()) + const capabilities = chainCapabilities(slug) + const chainRead = + capabilities === undefined + ? null + : readChainMessage(text, slug as ChainWithActions, descriptor.name) + const open = message !== null && parsed !== null && restored === null && !turnExecuted + + // Kept as it is shown. Two paths turn it away under a signature and never show it. + const refused = + executionInFlight && + (cashoutRoute.kind !== 'pass' || + (clarifying === null && open && classifyFollowUp(text) === 'note')) + if (!refused) { + recordLive('user', text) + if (keepsRun(chainSequence.stage, messageStartsAction(cashoutRoute, chainRead))) { + setSequenceNote(null) + } else { + leaveSequence() + } + } + + // "cash out to KES" at the question of a swap waiting on it answers that question, + // where read as a request it would drop the swap. + const routedAnswer = questionAnswerFrom(cashoutQuestion, cashoutRoute) + if (routedAnswer !== undefined) { + answerNamedCurrency(routedAnswer, text) + return + } + if (cashoutRoute.kind !== 'pass') { startCashout(cashoutRoute, text) return } + const stellarExit = slug === 'stellar' ? parseStellarExit(text) : null + if (stellarExit !== null) { + startStellarExit(stellarExit, text) + return + } + // The answer to "which currency?". Anything that isn't one closes the question // and is read as what it is, so nobody is held in it. if (cashoutQuestion !== null && cashoutQuestion.answer === undefined) { const answer = parseCurrencyAnswer(text) if (answer !== null) { - const offered = cashoutQuestion.choices - const named = 'currency' in answer ? answer.currency : answer.unsupported - if (offered.some((choice) => choice.code === named)) { - answerCashoutQuestion(named, text) - } else { - setCashoutQuestion({ - ...cashoutQuestion, - retry: { said: text, reply: unsupportedCurrencyReply(named, offered) }, - }) - } + answerNamedCurrency('currency' in answer ? answer.currency : answer.unsupported, text) return } } @@ -1479,25 +2260,43 @@ 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 - // below are Stellar's. Anything that isn't an instruction, such as a - // question, still goes to the orchestrator. - const capabilities = chainCapabilities(slug) - if (capabilities !== undefined) { - const action = parseChainAction(text) - if (action !== null) { - // The thread only renders once there is a message, so without this the - // card below would be built and never seen. + // Chains with their own module have their own path: the race and the swap routes + // below are Stellar's. What the strict parsers can't read in full goes to the + // orchestrator, verbs or not, and `readChainMessage` is what decides that. + if (chainRead !== null) { + if (chainRead.kind === 'refusal') { + setClarifying(null) setMessage(text) - chainAction.prepare(action, text) + clearChainRace() + chainAction.refuse(chainRead.refusal, text) + return + } + if (chainRead.kind === 'sequence') { + startChainSequence(chainRead.request, text) + return + } + if (chainRead.kind === 'cashout') { + startCashout(chainRead.route, text) return } - if (looksLikeChainAction(text)) { + if (chainRead.kind === 'action' || chainRead.kind === 'limit') { + startChainAction(chainRead.action, text) + return + } + if (chainRead.kind === 'orders') { + setClarifying(null) setMessage(text) - chainAction.refuse(chainHelp(descriptor.name, capabilities), text) + resetTurnState() + setParsed(null) + setTurnId(null) + clearChainRace() + chainAction.reset() + setOrdersShown(text) return } + // A step 2 card still unsigned goes too, and the run's button is what brings it back. chainAction.reset() + clearChainRace() } // An answer to a question already asked. @@ -1507,7 +2306,6 @@ export function IntentChat(): JSX.Element { } // A note on an open intent refines it and needs no question of its own. - const open = message !== null && parsed !== null && restored === null && !turnExecuted if (open && classifyFollowUp(text) === 'note') { void refine(text) return @@ -1530,7 +2328,7 @@ export function IntentChat(): JSX.Element { */ async function refine(note: string): Promise { if (executionInFlight) { - setAffordError('Finish or cancel the signature first, then continue the chat.') + setAffordError(FINISH_SIGNATURE_NOTICE) return } await clarify( @@ -1543,11 +2341,24 @@ export function IntentChat(): JSX.Element { ) } - /** What the wallet holds, from balances already read: Stellar lists every asset, other chains their native one. */ - function walletBalanceReply(): string { + /** What the wallet holds. Stellar lists every asset from balances already read; other chains are read from the chain now. */ + async function walletBalanceReply(): Promise { if (slug === 'stellar') return balanceMessage(isConnected ? balances : undefined) - if (!isConnected || nativeBalance === undefined) return balanceMessage(undefined) - return `Your wallet holds:${String.fromCharCode(10)}• ${nativeBalance} ${balanceSymbol}` + if (!isConnected || address === undefined) return balanceMessage(undefined) + let held: WalletBalances | undefined + try { + const response = await fetch( + `/api/chain/${slug}/balances?account=${encodeURIComponent(address)}`, + { cache: 'no-store', signal: AbortSignal.timeout(20_000) } + ) + if (response.ok) { + const body = (await response.json()) as Partial + if (Array.isArray(body.tokens)) held = body as WalletBalances + } + } catch { + held = undefined + } + return chainBalanceReply(held, descriptor.name) } async function clarify( @@ -1566,6 +2377,9 @@ export function IntentChat(): JSX.Element { setMessage(text) setTurnId(crypto.randomUUID()) } + latest.current.turn += 1 + const asked = { ...latest.current } + clarifyRuns.current += 1 setClarifying({ turns }) setClarifyBusy(true) @@ -1573,16 +2387,60 @@ export function IntentChat(): JSX.Element { const earlier = raceContext(thread, []) // The conversation so far, replies included. Without it every message is // read cold, and "explain that better" has nothing to improve on. - const answer = await fetchOrchestrate(turns, earlier, slug, fetch, dialogue.current) - setClarifyBusy(false) + // A question about positions needs no model, and takes the balance path from here on. + const positions = asksForPositions(text) + const answer = positions + ? { outcome: 'balance' as const } + : await fetchOrchestrate(turns, earlier, slug, fetch, dialogue.current) + // The busy indicator stays up until the holdings are read. A reply that arrives after the + // person moved to another wallet, chain or message is not theirs any more, so it is dropped. + const balanceText = positions + ? await readPositionsReply(slug, descriptor.name, isConnected ? address : undefined) + : answer.outcome === 'balance' + ? await walletBalanceReply() + : undefined + clarifyRuns.current -= 1 + if (clarifyRuns.current === 0) setClarifyBusy(false) + // The last wallet's question, answered after it left. A balance has its own rule below. + if (balanceText === undefined && asked.account !== latest.current.account) return + const decision = + balanceText === undefined + ? { kind: 'show' as const } + : balanceReplyDecision(asked, latest.current) + if (decision.kind === 'drop') return + if (decision.kind === 'notice') { + setClarifying(null) + setAffordError(decision.text) + return + } if (answer.outcome === 'ask') { setClarifying({ turns: [...turns, { role: 'assistant', content: answer.question }] }) + if (asked.turn === latest.current.turn) recordLive('agent', answer.question) return } setClarifying(null) + const chainCaps = chainCapabilities(slug) + const said = turns.filter((turn) => turn.role === 'user').map((turn) => turn.content) + + // No model read this. Why, or what works here if it asks for something, is said in its place. + if ( + answer.outcome === 'unavailable' && + chainCaps !== undefined && + asked.turn === latest.current.turn + ) { + const help = helpForUnread(answer.reason, said, descriptor.name, chainCaps) + if (help !== undefined) { + setParsed(null) + setFollowOn(null) + clearChainRace() + chainAction.refuse(help, turns[0]?.content ?? text) + return + } + } + // Nothing runs: the message is not an instruction, or could not be read. // The reply is the model's own, written for this message in this // conversation. Fixed text is only what is shown when there was no model to @@ -1594,7 +2452,7 @@ export function IntentChat(): JSX.Element { answer.outcome === 'help' ? (answer.reply ?? helpMessage(slug)) : answer.outcome === 'balance' - ? walletBalanceReply() + ? (balanceText as string) : answer.outcome === 'out_of_scope' ? outOfScopeMessage(slug, answer.reply) : answer.outcome === 'unclear' @@ -1625,10 +2483,52 @@ export function IntentChat(): JSX.Element { // conversation the next message belongs to. dialogue.current = [] + // Off Stellar nothing may start under whatever was said or pressed while this was being read. + if ( + chainCaps !== undefined && + (asked.turn !== latest.current.turn || asked.chain !== latest.current.chain) + ) { + return + } + // The model wrote out a cash-out, perhaps from words no parser here would read. const writtenOut = routeCashout(parseCashout(answer.instruction), slug, isMainnet()) if (writtenOut.kind !== 'pass') { - startCashout(writtenOut, turns[0]?.content ?? text) + const shown = turns[0]?.content ?? text + // What gets signed comes from the person's own words, never from the model's. + if (writtenOut.kind === 'sequence' && !cashoutSaid(writtenOut, said)) { + startCashout({ kind: 'token', token: writtenOut.token }, shown) + return + } + if (chainCaps !== undefined && cashoutStarts(writtenOut)) leaveSequence() + startCashout(writtenOut, shown) + return + } + + // Off Stellar a reading never reaches Stellar's swap and lend flows below. It goes where a typed + // sentence goes, and only what the person typed themselves can be signed. + if (chainCaps !== undefined) { + const shown = turns[0]?.content ?? text + const route = chainRouteFromReading( + answer.read, + answer.instruction, + said, + slug as ChainWithActions + ) + if (route !== null && 'sequence' in route) { + leaveSequence() + startChainSequence(route.sequence, shown) + } else if (route !== null && 'action' in route) { + leaveSequence() + startChainAction(route.action, shown) + } else { + setMessage(shown) + clearChainRace() + chainAction.refuse( + route !== null ? route.refusal : chainHelp(descriptor.name, chainCaps), + shown + ) + } return } @@ -1688,9 +2588,20 @@ export function IntentChat(): JSX.Element { /** A clean slate: the open intent, the turns before it and any notes. */ function newChat(): void { + // While a switch waits on a signature the chat on screen is neither wallet's to clear. + if (transcriptOwner !== undefined && transcriptOwner === chatOwner.current) { + clearLiveSession(liveSessionStorage(), segment, transcriptOwner) + } + clearOpenChat() + setSavedTurns(null) + } + + function clearOpenChat(): void { + latest.current.turn += 1 handleReset() setThread([]) setNotes([]) + setInSaved({ thread: 0, notes: 0 }) setClarifying(null) setClarifyBusy(false) setCashoutQuestion(null) @@ -1699,11 +2610,18 @@ export function IntentChat(): JSX.Element { } function handleReset(): void { + // The card holds a signature in flight; closing it would orphan the transaction. + if (chainSigning(chainAction.phase)) return + latest.current.turn += 1 + chainSequence.clear() + setSequenceNote(null) + if (slug !== 'stellar') chainAction.reset() // The thread stays while a cash-out is under way: it carries the warning not to resend. setMessage(cashout.locked ? message : null) cashout.dismiss() setCashoutQuestion(null) setParsed(null) + clearChainRace() setExecutingKey(null) setPlacedId(null) setAffordError(null) @@ -1719,6 +2637,15 @@ export function IntentChat(): JSX.Element { // read as a stuck transaction that had never existed. setFollowOn(null) sequence.reset() + // The turns so far stay on screen as a reload would show them. The thread and + // notes behind them still go to the agents, so they are hidden, not dropped. + const owner = chatOwner.current + if (owner === undefined) return + const turns = loadLiveSession(liveSessionStorage(), segment, owner) + setSavedTurns({ account: owner, turns }) + setInSaved( + turns.length > 0 ? { thread: thread.length, notes: notes.length } : { thread: 0, notes: 0 } + ) } /** @@ -1743,6 +2670,7 @@ export function IntentChat(): JSX.Element { } function handleExecute(key: string): void { + if (!canReview(competition.proposals[key], routesByAgent[key], restored !== null)) return // Selecting an agent is not a commitment, so it must stay changeable. // Guarding on `executingKey` — set by the first Review click — meant the // first pick was final: every other card became unclickable before the @@ -1751,6 +2679,31 @@ export function IntentChat(): JSX.Element { // A signature in flight is the real reason to refuse: past that point a // transaction is with the wallet or the network, and switching routes // would leave the wrong one submitted. + // A race on another chain signs through that chain's own card, never the + // Stellar swap hook. + if (slug !== 'stellar' && chainRace !== null) { + const outcome = reviewChainRoute({ + // Only this agent's own route: falling back to the winner's would sign + // a trade the user did not pick. + route: routesByAgent[key], + asked: chainRace.request, + text: chainRace.text, + phase: chainAction.phase, + signed: raceClosed(chainAction.signed === true, chainSequence.settled), + preparedVenueId: chainAction.prepared?.venueId, + ...(live.competitionId !== undefined ? { raceId: live.competitionId } : {}), + prepare: chainAction.prepare, + refuse: chainAction.refuse, + }) + if (outcome === 'prepared') { + setDirectFallback(false) + setExecutingKey(key) + } + return + } + // A chain route with no race open (a reopened turn) is not for the Stellar path. + if (isChainRoute(routesByAgent[key] ?? winnerRoute)) return + const inFlight = swap.phase === 'signing' || swap.phase === 'submitting' || swap.phase === 'settled' if (!parsed || inFlight) return @@ -1888,12 +2841,22 @@ export function IntentChat(): JSX.Element { const venue = followOn.venueNamed === true ? followOn.venue : (agentsVenue ?? followOn.venue) + // The token the swap delivers, by its own contract. This always named XLM's, so a swap into + // USDC went on to supply that many XLM. Said before the swap, not after it has run. + const lendAsset = suppliableReserve(parsed.input.tokenOut) + if (venue === 'blend' && lendAsset === undefined) { + setAffordError( + `Blend does not take ${parsed.input.tokenOut} here, so this cannot end in a supply. Nothing was signed or sent.` + ) + return + } + sequence.prepare({ kind: 'swap-then-lend', ...slippageEdit, quote: route, receiveSymbol: parsed.input.tokenOut, - lendAsset: blendXlm(), + lendAsset: lendAsset ?? '', venue, swapLabel: `Swap ${parsed.input.amountIn} ${parsed.input.tokenIn} for ${parsed.input.tokenOut}`, }) @@ -2145,6 +3108,15 @@ export function IntentChat(): JSX.Element { { + if (chainSigning(chainAction.phase)) return + leaveSequence() + // Off Stellar, a price or check still on its way must not start anything over this. + if (chainCapabilities(slug) !== undefined) latest.current.turn += 1 + // A race on screen ends when another turn is opened. + if (chainRace !== null) { + clearChainRace() + 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. @@ -2192,7 +3164,14 @@ export function IntentChat(): JSX.Element { Even the error message lived in this dead branch, so a failure was as invisible as a success. */} {!message ? ( -
+
0 + ? 'flex flex-col gap-4' + : 'flex h-full flex-col items-center justify-center gap-4 text-center' + } + > + {/* A swap from an earlier session settled meaning to be cashed out, and the tab closed before that second step started. The swap itself is already safe in history; this is only @@ -2223,22 +3202,27 @@ export function IntentChat(): JSX.Element {
) : null} - - - -

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

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

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

+ + ) : null}
) : (
+ {/* A reopened conversation shows the prices that were quoted when it ran. Saying so matters: signing against a rate from an hour ago is a different decision from signing a fresh one. */} @@ -2263,7 +3247,7 @@ export function IntentChat(): JSX.Element { ) : null} {/* The turns before this one, quieter than the open intent. */} - {thread.map((turn) => ( + {thread.slice(inSaved.thread).map((turn) => (
{turn.text}
@@ -2341,7 +3325,7 @@ export function IntentChat(): JSX.Element { ) : null} {/* What was added to the open intent while it was being decided. */} - {notes.map((note, i) => ( + {notes.slice(inSaved.notes).map((note, i) => (
{note}
@@ -2363,12 +3347,19 @@ export function IntentChat(): JSX.Element { ) ) : null} + {sequenceNote !== null && restored === null ? ( +
+
{sequenceNote}
+
+ ) : null} {cashoutQuestion !== null ? ( - answerCashoutQuestion(choice.code, `${choice.name} (${choice.code})`) - } + onChoose={(choice) => { + const said = `${choice.name} (${choice.code})` + recordLive('user', said) + answerCashoutQuestion(choice.code, said) + }} /> ) : null} {clarifyBusy ? ( @@ -2384,8 +3375,18 @@ export function IntentChat(): JSX.Element { locked={ swap.phase === 'signing' || swap.phase === 'submitting' || - swap.phase === 'settled' + swap.phase === 'settled' || + chainReviewClosed( + chainAction.phase, + raceClosed(chainAction.signed === true, chainSequence.settled) + ) } + directRoute={directFallback} + reviewable={(key) => + canReview(competition.proposals[key], routesByAgent[key], restored !== null) + } + signed={raceClosed(chainAction.requested, chainSequence.settled)} + {...(slug !== 'stellar' ? { chainName: descriptor.name } : {})} /> {/* Changes to how the recommendation is carried out, offered once the agents have answered and before anything is signed. */} @@ -2451,6 +3452,7 @@ export function IntentChat(): JSX.Element { ? { balanceUsdc: balances.trustlines['USDC'].balance } : {})} onOrder={(order) => { + recordStellarTx('The cash-out payment was sent.', order.paymentHash) if (turnId === null) return updateTurn( turnId, @@ -2559,8 +3561,30 @@ export function IntentChat(): JSX.Element { {/* Supplying what the account already holds. One transaction, one signature — no sequence to explain. */} {supply.phase !== 'idle' ? : null} + {exitWithdraw.phase !== 'idle' ? : null} + {exitRepay.phase !== 'idle' ? : null} - {chainAction.phase !== 'idle' ? : null} + {restored === null ? ( + + ) : null} + + {showDirectNote(directFallback, chainAction.phase, chainSequence.settled) ? ( +

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

+ ) : null} + {chainAction.phase !== 'idle' ? ( +
+ +
+ ) : null} + {ordersShown !== null ? ( + + ) : null} {cashout.session !== null ? ( @@ -2576,6 +3600,7 @@ export function IntentChat(): JSX.Element { { + recordStellarTx('The plan settled.', hash) if (turnId !== null) { updateTurn(turnId, { txHash: hash }) setTurns(loadTurns(slug)) @@ -2590,6 +3615,7 @@ export function IntentChat(): JSX.Element { sellSymbol={parsed.input.tokenIn} buySymbol={parsed.input.tokenOut} onPlaced={(hash) => { + recordStellarTx('The order was placed.', hash) // The placing transaction is a real, verifiable event, but it // is not a fill — the order is only now waiting. Recording it // on the conversation lets history link to it without @@ -2624,11 +3650,7 @@ export function IntentChat(): JSX.Element { {/* Everything currently resting, read from the ledger rather than remembered here, so a fill that happened elsewhere still shows. */} - limit.cancel(offer.id, offer.sellingAsset, offer.buyingAsset)} - /> + {/* Appears only once an agent has won with an executable route, so the race is never interrupted by a confirmation prompt. @@ -2693,6 +3715,7 @@ export function IntentChat(): JSX.Element { // the reminder below exists to chase is done, whatever // happens to the order from here. clearPendingCashOut() + recordStellarTx('The cash-out payment was sent.', order.paymentHash) if (turnId === null) return updateTurn( turnId, @@ -2721,7 +3744,7 @@ export function IntentChat(): JSX.Element { > - {createIntent.isError ? (

{(createIntent.error as Error).message || 'Something went wrong. No funds moved.'} diff --git a/apps/dapp/components/intents/open-orders.tsx b/apps/dapp/components/intents/open-orders.tsx index 622a615b..eb723f05 100644 --- a/apps/dapp/components/intents/open-orders.tsx +++ b/apps/dapp/components/intents/open-orders.tsx @@ -1,9 +1,11 @@ 'use client' import { Button, Card } from '@intent/ui' -import { useQuery } from '@tanstack/react-query' -import { Loader2 } from 'lucide-react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ExternalLink, Loader2 } from 'lucide-react' +import { useEffect } from 'react' +import { useLimitOrder, type LimitOrder } from '../../hooks/use-limit-order' import type { OpenOffer } from '../../lib/swap/offers' import { TokenIcon } from '../ui/token-icon' @@ -17,6 +19,10 @@ import { TokenIcon } from '../ui/token-icon' * * A shrinking amount is a partial fill, visible without any bookkeeping of our * own: Stellar decrements the offer as it trades. + * + * Withdrawing runs on its own hook, separate from whatever order is being placed + * beside it. Sharing one showed a withdrawal as "order placed", and with no order + * being placed, showed nothing at all, even when it failed. */ async function loadOffers(account: string): Promise { @@ -26,16 +32,48 @@ async function loadOffers(account: string): Promise { return body.offers ?? [] } -export function OpenOrders({ - account, - onCancel, - cancelling, -}: { - account: string | undefined - onCancel: (offer: OpenOffer) => void - /** True while a cancellation is in flight, so the buttons can settle. */ - cancelling?: boolean -}): JSX.Element | null { +function WithdrawStatus({ cancel }: { cancel: LimitOrder }): JSX.Element | null { + const { phase } = cancel + if (phase === 'checking' || phase === 'signing' || phase === 'submitting') { + return ( +

+ + {phase === 'signing' ? 'Waiting for your wallet…' : 'Withdrawing the order…'} +

+ ) + } + if (phase === 'cancelled') { + return ( +

+ Order withdrawn. What had not traded is back in your wallet. + {cancel.explorerUrl !== undefined ? ( + + View transaction + + + ) : null} +

+ ) + } + if (phase === 'failed') { + return ( +

+ {cancel.error ?? 'The order was not withdrawn.'} It is still resting, and your funds have + not moved. +

+ ) + } + return null +} + +export function OpenOrders({ account }: { account: string | undefined }): JSX.Element | null { + const cancel = useLimitOrder() + const queryClient = useQueryClient() const { data, isLoading, error } = useQuery({ queryKey: ['open-offers', account], queryFn: () => loadOffers(account as string), @@ -44,6 +82,11 @@ export function OpenOrders({ refetchInterval: 15_000, }) + useEffect(() => { + if (cancel.phase !== 'cancelled') return + void queryClient.invalidateQueries({ queryKey: ['open-offers', account] }) + }, [cancel.phase, queryClient, account]) + if (account === undefined) return null if (isLoading) { @@ -66,48 +109,58 @@ export function OpenOrders({ } const offers = data ?? [] - if (offers.length === 0) return null + const busy = + cancel.phase === 'checking' || cancel.phase === 'signing' || cancel.phase === 'submitting' + + // The last order just withdrawn still owes its confirmation. + if (offers.length === 0 && cancel.phase === 'idle') return null return ( - - Resting {offers.length === 1 ? 'order' : 'orders'} - - -
    - {offers.map((offer) => ( -
  • -
    - - - {offer.remaining} {offer.sellingAsset} - for - - {offer.buyingAsset} - - - at {offer.price} {offer.buyingAsset} each - -
    - - -
  • - ))} -
+ {offers.length > 0 ? ( + <> + + Resting {offers.length === 1 ? 'order' : 'orders'} + -

- These sit on Stellar and fill on their own. Withdrawing returns whatever has not traded. -

+
    + {offers.map((offer) => ( +
  • +
    + + + {offer.remaining} {offer.sellingAsset} + for + + {offer.buyingAsset} + + + at {offer.price} {offer.buyingAsset} each + +
    + + +
  • + ))} +
+ +

+ These sit on Stellar and fill on their own. Withdrawing returns whatever has not traded. +

+ + ) : null} + +
) } diff --git a/apps/dapp/components/intents/open-positions.tsx b/apps/dapp/components/intents/open-positions.tsx index 0b7fc055..c219fb3c 100644 --- a/apps/dapp/components/intents/open-positions.tsx +++ b/apps/dapp/components/intents/open-positions.tsx @@ -3,7 +3,7 @@ import { Button, Card } from '@intent/ui' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' -import { ArrowRight, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { useWallet } from '../../hooks/use-wallet' import { useBlendPosition } from '../../hooks/use-blend-position' @@ -13,6 +13,7 @@ import { useCollateral } from '../../hooks/use-collateral' import { useWithdrawalStatus } from '../../hooks/use-withdrawal-status' import { BorrowConfirm } from './borrow-confirm' import { LIQUIDATION_HF, MINIMUM_SAFE_HF } from '../../lib/lend/health' +import { blendReserves } from '../../lib/lend/reserves' import { loadTurns, onTurnsChanged } from '../../lib/chat-history' import { lookupAnchor } from '../../lib/offramp/anchors' import { pendingWithdrawals } from '../../lib/offramp/pending-withdrawals' @@ -21,6 +22,8 @@ import { blendPositionUrl } from '../../lib/swap/contract-registry' import { toBaseUnits } from '../../lib/swap/assets' import { useChain } from '../../providers/chain-provider' import { TokenIcon } from '../ui/token-icon' +import { OpenOrders } from './open-orders' +import { PoolPositions, loadPoolShares } from './pool-positions' import { WithdrawConfirm } from './withdraw-confirm' /** @@ -81,20 +84,73 @@ function typedBaseUnits(draft: string, heldBase: string): string | undefined { return base } -/** - * What this pool lends. - * - * Read from the pool's reserve list rather than a ticker table: Blend's USDC - * is a different issuer from Circle's, so an asset that looks familiar can be - * one this pool has never heard of. These four are its whole reserve list, - * confirmed by reading each contract's own `symbol` rather than inferring it. - */ -const BORROWABLE = [ - { id: 'CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU', symbol: 'USDC' }, - { id: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', symbol: 'XLM' }, - { id: 'CAP5AMC2OHNVREO66DFIN6DHJMPOBAJ2KCDDIMFBR7WWJH5RZBFM3UEI', symbol: 'wBTC' }, - { id: 'CAZAQB3D7KSLSNOSQKYD2V4JP5V2Y3B4RDJZRLBFCCIXDCTE3WHSY3UE', symbol: 'wETH' }, -] +/** A typed amount, a button for it, and a button for all of it. "All" is the only way to empty one. */ +function AmountAction({ + label, + symbol, + held, + busy, + onSome, + onAll, + secondary, +}: { + label: string + symbol: string + /** Base units. A typed figure above this is refused rather than quietly changed. */ + held: string + busy: boolean + onSome: (baseUnits: string) => void + onAll: () => void + /** A second use for the typed amount. */ + secondary?: { label: string; disabled: boolean; onSome: (baseUnits: string) => void } +}): JSX.Element { + // Held as text rather than a number: a half-written "5." is a valid thing to + // be typing and not a valid number. + const [draft, setDraft] = useState('') + const typed = typedBaseUnits(draft, held) + + return ( +
+ setDraft(e.target.value)} + inputMode="decimal" + placeholder="Amount" + aria-label={`Amount of ${symbol} to ${label.toLowerCase()}`} + disabled={busy} + className="border-border bg-background focus:border-foreground/40 w-24 rounded-md border px-2 py-1.5 text-sm tabular-nums outline-none transition-colors disabled:opacity-50" + /> + + {/* Distinct from typing the balance shown. That figure is stale the moment + it renders, as interest moves it every ledger, so only this path can + actually empty it. */} + + {secondary !== undefined ? ( + + ) : null} +
+ ) +} /** How a health factor reads to somebody who has never seen one. */ function healthTone(hf: number | undefined): string { @@ -106,15 +162,15 @@ function healthTone(hf: number | undefined): string { export function OpenPositions(): JSX.Element | null { const { address, isConnected } = useWallet() - const { position, positions } = useBlendPosition() + const { positions, error: blendUnread } = useBlendPosition() const withdraw = useWithdraw() const borrow = useBorrow() const queryClient = useQueryClient() - // What the user has typed, held as text rather than a number: a half-written - // "5." is a valid thing to be typing and not a valid number. - const [draft, setDraft] = useState('') + // What this network's pool lends: a list for the wrong network offers a loan + // the pool has never heard of. + const borrowable = blendReserves() const [borrowDraft, setBorrowDraft] = useState('') - const [borrowAsset, setBorrowAsset] = useState(BORROWABLE[0]?.id ?? '') + const [borrowAsset, setBorrowAsset] = useState(borrowable[0]?.id ?? '') // Posting collateral is a `submit` like any other, so it reuses the same // build-review-sign machinery rather than a bespoke path. const collateral$ = useCollateral() @@ -134,30 +190,36 @@ export function OpenPositions(): JSX.Element | null { // just been paid looked like it had not been recorded at all. useEffect(() => onTurnsChanged(() => setPending(pendingWithdrawals(loadTurns(slug)))), [slug]) - const { data: offers } = useQuery({ + const { data: offers, isError: offersUnread } = useQuery({ queryKey: ['open-offers', address], queryFn: () => loadOffers(address as string), enabled: isConnected && address !== undefined, refetchInterval: 15_000, }) + const { data: poolShares, isError: poolsUnread } = useQuery({ + queryKey: ['pool-shares', address], + queryFn: () => loadPoolShares(address as string), + enabled: isConnected && address !== undefined, + refetchInterval: 60_000, + }) /** - * Converts part of a plain supply into collateral. + * Posts collateral, from the wallet. * - * Confirmed first, because this is the step that spends the guarantee: a - * plain supply cannot be liquidated and collateral can, and no other action - * in this app changes that. The wallet prompt that follows says nothing - * about seizure, so the app has to. + * Confirmed first, because this is the step that takes on the risk: a plain + * supply cannot be liquidated and collateral can. It does not convert a supply: + * Blend's `apply_supply_collateral` takes the amount from the spender. The wallet + * prompt that follows says nothing about seizure, so the app has to. */ function postCollateral(baseUnits: string, assetId: string, symbol: string): void { const display = (Number(baseUnits) / 1e7).toLocaleString(undefined, { maximumFractionDigits: 7, }) const agreed = window.confirm( - `Post ${display} ${symbol} as collateral?\n\n` + - 'It keeps earning, and it can back a loan. It can also be taken from you if a loan ' + + `Post ${display} ${symbol} from your wallet as collateral?\n\n` + + 'It earns, and it can back a loan. It can also be taken from you if a loan ' + 'you open against it falls below its liquidation threshold.\n\n' + - 'A plain supply carries no such risk. This one will.' + 'What you already have supplied stays as it is, and carries no such risk.' ) if (!agreed) return @@ -172,19 +234,23 @@ export function OpenPositions(): JSX.Element | null { if (withdraw.phase !== 'settled') return void queryClient.invalidateQueries({ queryKey: ['blend-position', address] }) }, [withdraw.phase, queryClient, address]) + useEffect(() => { + if (borrow.phase !== 'settled') return + void queryClient.invalidateQueries({ queryKey: ['blend-position', address] }) + }, [borrow.phase, queryClient, address]) - const resting = offers ?? [] + const supplied = positions?.supplied ?? [] const collateral = positions?.collateral ?? [] const borrowed = positions?.borrowed ?? [] const count = - resting.length + - (position != null ? 1 : 0) + + (offers?.length ?? 0) + + (poolShares?.length ?? 0) + + supplied.length + collateral.length + borrowed.length + pending.length const busy = withdraw.phase !== 'idle' - const typed = position == null ? undefined : typedBaseUnits(draft, position.amount) // A borrow is not bounded by a balance the way a withdrawal is, so this // only checks the figure is well formed. The pool decides whether the @@ -203,7 +269,8 @@ export function OpenPositions(): JSX.Element | null { // Nothing open is a perfectly good state and says so once, rather than // rendering an empty titled section that reads as something failing to load. - if (!isConnected || count === 0) return null + // A read that failed is not "nothing open", which is the one thing this must not imply. + if (!isConnected || (count === 0 && !blendUnread && !offersUnread && !poolsUnread)) return null return (
@@ -212,11 +279,19 @@ export function OpenPositions(): JSX.Element | null { {count}
+ {blendUnread ? ( + + Could not read your Blend position from the network, so this list may be missing money you + hold there. It will try again shortly. + + ) : null} + {/* Lending first: it earns continuously and has no expiry, so it is the - position most easily forgotten. */} - {position != null ? ( - - + position most easily forgotten. Every asset, not only XLM: a supply of + anything else used to be invisible here, and so had no way out. */} + {supplied.map((held) => ( + +
@@ -224,7 +299,7 @@ export function OpenPositions(): JSX.Element | null { earning
- {amount(position.display)} {position.symbol} + {amount(held.display)} {held.symbol}
@@ -233,58 +308,28 @@ export function OpenPositions(): JSX.Element | null { offer a withdrawal for a plain (non-collateral) supply, which is how a real position became visible here and retrievable nowhere. */} - setDraft(e.target.value)} - inputMode="decimal" - placeholder="Amount" - aria-label={`Amount of ${position.symbol} to withdraw`} - disabled={busy} - className="border-border bg-background focus:border-foreground/40 w-24 rounded-md border px-2 py-1.5 text-sm tabular-nums outline-none transition-colors disabled:opacity-50" - /> - - {/* Distinct from typing the balance shown. That figure is stale the - moment it renders -- the position earns every ledger -- so only - this path can actually empty it. */} - - {/* The one action here that takes a guarantee away. A plain supply - cannot be liquidated; collateral can. Typing an amount and - pressing this converts that much of it, and the confirmation - says so plainly rather than treating it as a transfer. */} - + />
- ) : null} + ))} {/* Sits directly under the position it acts on, so the amount being withdrawn and the amount held are readable together. */} @@ -314,7 +359,7 @@ export function OpenPositions(): JSX.Element | null { {collateral$.error ?? 'Something went wrong.'}

- Nothing moved, and your supply is unchanged. + Nothing moved, and your wallet is unchanged.