diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 1912e04a..931b60bd 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -1,218 +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'))" -# 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 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, 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= +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]/layout.tsx b/apps/dapp/app/[chain]/layout.tsx index 7d5571ff..fa15df92 100644 --- a/apps/dapp/app/[chain]/layout.tsx +++ b/apps/dapp/app/[chain]/layout.tsx @@ -5,7 +5,7 @@ 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' /** @@ -29,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 = @@ -47,7 +38,8 @@ export default async function ChainLayout({ return ( - + + {children} diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts index 804e6586..c44264da 100644 --- a/apps/dapp/app/api/agents/compete/route.ts +++ b/apps/dapp/app/api/agents/compete/route.ts @@ -17,7 +17,7 @@ import { getRoster } from '../../../../lib/agents/registry' import type { PlanContext } from '../../../../lib/agents/plan-scoring' import { pickWinner, scoreProposals, unanimousChoice } from '../../../../lib/agents/scoring' import { - assertAmountWithinCap, + assertValidAmount, assertKnownTokens, assertNativeReserve, } from '../../../../lib/chains/prepare' @@ -138,8 +138,8 @@ async function chainRaceFacts( } } try { - const known = assertKnownTokens(chainApi, action) - await assertAmountWithinCap(chainApi, action, known) + assertKnownTokens(chainApi, action) + assertValidAmount(action) } catch (e) { if (e instanceof ChainActionRefused) return { refusal: `${e.message} Nothing was signed or sent.` } 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]/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 2162df90..8020930f 100644 --- a/apps/dapp/app/api/chain/[chain]/prepare/route.ts +++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts @@ -14,15 +14,45 @@ 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', 'send']), + kind: z.enum([ + 'swap', + 'supply', + 'stake', + 'send', + '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(), + 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(), }), }) @@ -44,7 +74,18 @@ export async function POST( } try { - const { kind, amount, tokenIn, tokenOut, venue, recipient } = parsed.data.request + const { + kind, + amount, + tokenIn, + tokenOut, + venue, + recipient, + durationDays, + dueDate, + invoiceId, + streamId, + } = parsed.data.request const prepared = await prepareChainAction( chainApi, { @@ -54,6 +95,10 @@ export async function POST( ...(tokenOut !== undefined ? { tokenOut } : {}), ...(venue !== undefined ? { venue } : {}), ...(recipient !== undefined ? { recipient } : {}), + ...(durationDays !== undefined ? { durationDays } : {}), + ...(dueDate !== undefined ? { dueDate } : {}), + ...(invoiceId !== undefined ? { invoiceId } : {}), + ...(streamId !== undefined ? { streamId } : {}), }, parsed.data.account ) 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/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 5cded506..7f37437e 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -298,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.'}