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
onPick(slug)} className={rowClass}>
{CHAIN_DESCRIPTORS[slug].name}
+
))}
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.'}
- {action.building === 'send' ? 'Building this transfer' : 'Getting a live price'}
+ {action.building === 'swap' || action.building === 'supply' || action.building === 'stake'
+ ? 'Getting a live price'
+ : 'Building this transaction'}
) : null}
@@ -62,9 +90,7 @@ export function ChainActionConfirm({
{prepared.summary}
- {prepared.kind === 'send'
- ? `This moves ${prepared.pay.amount} ${prepared.pay.symbol} from your wallet`
- : `You pay ${prepared.pay.amount} ${prepared.pay.symbol} on ${prepared.venueName}`}
+ {cardLine(prepared)}
{prepared.receive !== undefined
? `, and receive ${prepared.receive.approximate ? 'about ' : ''}${prepared.receive.amount} ${prepared.receive.symbol}`
: ''}
diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx
index 5fd2301e..fe3c1f4f 100644
--- a/apps/dapp/components/intents/intent-chat.tsx
+++ b/apps/dapp/components/intents/intent-chat.tsx
@@ -83,6 +83,7 @@ import { chainCapabilities } from '../../lib/chains/capabilities'
import { sizeInUnits } from '../../lib/chains/dollar-amount'
import { chainHelp, helpForUnread } from '../../lib/chains/explain'
import { racesFirst } from '../../lib/chains/parse-action'
+import { asksForPositions, readPositionsReply } from '../../lib/chains/positions-reply'
import {
cashoutStarts,
chainRouteFromReading,
@@ -167,6 +168,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'
@@ -175,14 +180,15 @@ 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 { 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'
@@ -613,6 +619,9 @@ export function IntentChat({
// 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(() => {
@@ -991,6 +1000,16 @@ export function IntentChat({
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)
@@ -1054,6 +1073,10 @@ export function IntentChat({
swap.phase === 'submitting' ||
sequence.phase === 'signing' ||
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
@@ -1085,6 +1108,8 @@ export function IntentChat({
dialogue.current = []
setPending(null)
supply.reset()
+ exitWithdraw.reset()
+ exitRepay.reset()
perp.reset()
planExec.reset()
limit.reset()
@@ -1193,6 +1218,8 @@ export function IntentChat({
sequence.reset()
swap.reset()
supply.reset()
+ exitWithdraw.reset()
+ exitRepay.reset()
perp.reset()
setExecutingKey(null)
setPlacedId(null)
@@ -1496,15 +1523,23 @@ export function IntentChat({
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),
})
@@ -1666,15 +1701,23 @@ export function IntentChat({
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.
@@ -1750,6 +1793,59 @@ export function IntentChat({
})()
}
+ /** 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
@@ -2088,8 +2184,9 @@ export function IntentChat({
*/
function handleSubmit(typed: string): void {
latest.current.turn += 1
- // A misspelled verb is corrected before anything reads the sentence.
- const text = fixVerbTypo(typed)
+ // 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)) {
@@ -2132,6 +2229,12 @@ export function IntentChat({
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) {
@@ -2262,10 +2365,18 @@ export function IntentChat({
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)
+ // 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 = answer.outcome === 'balance' ? await walletBalanceReply() : undefined
+ 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.
@@ -2702,12 +2813,22 @@ export function IntentChat({
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}`,
})
@@ -3409,6 +3530,8 @@ export function IntentChat({
{/* 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}
{restored === null ? (
@@ -3493,11 +3616,7 @@ export function IntentChat({
{/* 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.
@@ -3599,7 +3718,6 @@ export function IntentChat({
: {})}
{...(parsed ? { onReset: handleReset } : {})}
/>
-
{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.length > 0 ? (
+ <>
+
+ Resting {offers.length === 1 ? 'order' : 'orders'}
+
-
- These sit on Stellar and fill on their own. Withdrawing returns whatever has not traded.
-
+
+
+
+ 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"
+ />
+ {
+ if (typed !== undefined) onSome(typed)
+ }}
+ disabled={busy || typed === undefined}
+ >
+ {label}
+
+ {/* 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. */}
+
+ All
+
+ {secondary !== undefined ? (
+ {
+ if (typed !== undefined) secondary.onSome(typed)
+ }}
+ disabled={busy || typed === undefined || secondary.disabled}
+ >
+ {secondary.label}
+
+ ) : 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"
- />
- {
- if (typed === undefined) return
+
withdraw.prepare({
- assetId: position.assetId,
- symbol: position.symbol,
- amount: typed,
+ assetId: held.assetId,
+ symbol: held.symbol,
+ amount: baseUnits,
+ from: 'supply',
})
- }}
- disabled={busy || typed === undefined}
- >
- Withdraw
-
- {/* 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. */}
-
- withdraw.prepare({ assetId: position.assetId, symbol: position.symbol })
}
- disabled={busy}
- >
- All
-
- {/* 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. */}
- {
- if (typed === undefined) return
- postCollateral(typed, position.assetId, position.symbol)
+ onAll={() =>
+ withdraw.prepare({ assetId: held.assetId, symbol: held.symbol, from: 'supply' })
+ }
+ secondary={{
+ label: 'As collateral',
+ disabled: collateral$.phase !== 'idle',
+ onSome: (baseUnits) => postCollateral(baseUnits, held.assetId, held.symbol),
}}
- disabled={busy || typed === undefined || collateral$.phase !== 'idle'}
- >
- As collateral
-
+ />
- ) : 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.
- This much of your supply stops being untouchable. It keeps earning the same yield,
- and it becomes seizable if a loan you open against it falls below its liquidation
- threshold.
+ This much moves from your wallet into the pool. It earns the same yield as a supply,
+ and it can be seized if a loan you open against it falls below its liquidation
+ threshold. What you already have supplied stays as it is.
(
-
+
Collateral in Blend
- backing your loans
+
+ {borrowed.length > 0 ? 'backing your loans' : 'earning'}
+
{amount(held.display)} {held.symbol}
-
+
+ {/* The way out, which this row did not have. The pool pays it to the
+ wallet, and refuses if a loan still needs it. */}
+
+ withdraw.prepare({
+ assetId: held.assetId,
+ symbol: held.symbol,
+ amount: baseUnits,
+ from: 'collateral',
+ })
+ }
+ onAll={() =>
+ withdraw.prepare({
+ assetId: held.assetId,
+ symbol: held.symbol,
+ from: 'collateral',
+ })
+ }
+ />
{/* Which asset to borrow, since it is rarely the one posted. The
- pool lends four and they are not interchangeable — wBTC at
- $100,000 against XLM at $0.42 means a typo of one decimal is
- the difference between a safe loan and an instant liquidation. */}
+ pool lends several and they are not interchangeable: a typo of
+ one decimal is the difference between a safe loan and an instant
+ liquidation. */}
setBorrowAsset(e.target.value)}
@@ -413,7 +483,7 @@ export function OpenPositions(): JSX.Element | null {
disabled={borrow.phase !== 'idle'}
className="border-border bg-background rounded-md border px-2 py-1.5 text-sm outline-none disabled:opacity-50"
>
- {BORROWABLE.map((asset) => (
+ {borrowable.map((asset) => (
{asset.symbol}
@@ -435,7 +505,7 @@ export function OpenPositions(): JSX.Element | null {
if (borrowUnits === undefined) return
borrow.prepare('borrow', {
assetId: borrowAsset,
- symbol: BORROWABLE.find((a) => a.id === borrowAsset)?.symbol ?? '',
+ symbol: borrowable.find((a) => a.id === borrowAsset)?.symbol ?? '',
amount: borrowUnits,
})
}}
@@ -449,7 +519,7 @@ export function OpenPositions(): JSX.Element | null {
{/* Debt. The only row here that grows on its own. */}
{borrowed.map((owed) => (
-
+
@@ -473,14 +543,20 @@ export function OpenPositions(): JSX.Element | null {
) : null}
- borrow.prepare('repay', { assetId: owed.assetId, symbol: owed.symbol })}
- disabled={borrow.phase !== 'idle'}
- >
- Repay all
-
+
+ borrow.prepare('repay', {
+ assetId: owed.assetId,
+ symbol: owed.symbol,
+ amount: baseUnits,
+ })
+ }
+ onAll={() => borrow.prepare('repay', { assetId: owed.assetId, symbol: owed.symbol })}
+ />
))}
@@ -491,30 +567,10 @@ export function OpenPositions(): JSX.Element | null {
collateralSymbol={collateral[0]?.symbol}
/>
- {resting.map((offer) => (
-
-
-
-
-
- Limit order
- resting
-
-
-
- {amount(offer.remaining)} {offer.sellingAsset}
-
-
-
-
- {offer.buyingAsset}
-
-
-
+ {/* Read from the ledger, each with its way out. */}
+
- @ {offer.price}
-
- ))}
+
{pending.map((w) => {
const anchor = lookupAnchor(w.anchor.id)
diff --git a/apps/dapp/components/intents/pool-positions.tsx b/apps/dapp/components/intents/pool-positions.tsx
new file mode 100644
index 00000000..6a38cade
--- /dev/null
+++ b/apps/dapp/components/intents/pool-positions.tsx
@@ -0,0 +1,144 @@
+'use client'
+
+import { Button, Card } from '@intent/ui'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { ExternalLink, Loader2 } from 'lucide-react'
+import { useEffect } from 'react'
+
+import { usePoolWithdraw } from '../../hooks/use-pool-withdraw'
+import type { HeldPoolShare } from '../../lib/swap/liquidity-pools'
+
+/**
+ * Shares held in Stellar's built-in liquidity pools, with the way back out.
+ *
+ * Read from the account's balances, so a share deposited anywhere shows here.
+ */
+
+export async function loadPoolShares(account: string): Promise {
+ const res = await fetch(`/api/pools?account=${encodeURIComponent(account)}`)
+ if (!res.ok) throw new Error('could not read pool shares')
+ const body = (await res.json()) as { shares?: HeldPoolShare[] }
+ return body.shares ?? []
+}
+
+function figure(raw: string): string {
+ const n = Number(raw)
+ return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 4 }) : raw
+}
+
+export function PoolPositions({ account }: { account: string | undefined }): JSX.Element | null {
+ const withdraw = usePoolWithdraw()
+ const queryClient = useQueryClient()
+ const { data, error } = useQuery({
+ queryKey: ['pool-shares', account],
+ queryFn: () => loadPoolShares(account as string),
+ enabled: account !== undefined,
+ refetchInterval: 60_000,
+ })
+
+ useEffect(() => {
+ if (withdraw.phase !== 'settled') return
+ void queryClient.invalidateQueries({ queryKey: ['pool-shares', account] })
+ }, [withdraw.phase, queryClient, account])
+
+ if (account === undefined) return null
+ if (error !== null) {
+ return (
+
+ Could not read your pool shares from the network.
+
+ )
+ }
+
+ const shares = data ?? []
+ if (shares.length === 0 && withdraw.phase === 'idle') return null
+ const busy = withdraw.phase === 'signing' || withdraw.phase === 'submitting'
+
+ return (
+ <>
+ {shares.map((share) => (
+
+
+
+ Liquidity pool share
+ earning trading fees
+
+
+ {share.amounts.map((a) => `${figure(a.amount)} ${a.code}`).join(' + ')}
+
+
+ withdraw.prepare(share.poolId)}
+ disabled={withdraw.phase !== 'idle'}
+ >
+ Withdraw all
+
+
+ ))}
+
+ {withdraw.phase === 'building' ? (
+
+
+ Reading what your share is worth…
+
+ ) : null}
+
+ {withdraw.phase === 'review' || busy ? (
+
+ Withdraw this pool share
+
+ Both assets come back to your wallet, in the mix the pool holds now rather than the mix
+ you put in. You receive at least{' '}
+ {(withdraw.floors ?? []).map((f) => `${figure(f.amount)} ${f.code}`).join(' and ')}. If
+ the pool moves further than that first, the network refuses it and your share stays put.
+
+
+
+ {busy ? (
+
+
+ {withdraw.phase === 'signing' ? 'Waiting for your wallet…' : 'Submitting…'}
+
+ ) : (
+ 'Withdraw'
+ )}
+
+ {!busy ? (
+
+ Cancel
+
+ ) : null}
+
+
+ ) : null}
+
+ {withdraw.phase === 'settled' || withdraw.phase === 'failed' ? (
+
+
+ {withdraw.phase === 'settled'
+ ? 'Withdrawn. Both assets are back in your wallet.'
+ : `${withdraw.error ?? 'Something went wrong.'} Your share has not moved.`}
+
+
+
+ ) : null}
+ >
+ )
+}
diff --git a/apps/dapp/components/intents/swap-confirm.tsx b/apps/dapp/components/intents/swap-confirm.tsx
index c1a321ff..c52ad079 100644
--- a/apps/dapp/components/intents/swap-confirm.tsx
+++ b/apps/dapp/components/intents/swap-confirm.tsx
@@ -44,14 +44,8 @@ import { LedgerPreview } from './ledger-preview'
/**
* The threshold itself, inlined at build time like every other
- * `NEXT_PUBLIC_` value in this app. Not read from `trade-cap.ts`'s
- * `MAINNET_MAX_TRADE_USD`: that is a server-side hard cap enforced at submit
- * time, checked against the live price table rather than the client's
- * `usdPrices` snapshot, and it exists to refuse a trade outright. This is a
- * client-side pause that exists to slow a human down, and the two moving
- * independently is deliberate — raising the hard cap to let bigger trades
- * through should not silently also raise how much friction a human sees
- * first, and vice versa.
+ * `NEXT_PUBLIC_` value in this app. A client-side pause that exists to slow a
+ * human down, and it refuses nothing.
*/
const CONFIRM_PAUSE_THRESHOLD_USD = Number(
process.env.NEXT_PUBLIC_SWAP_CONFIRM_THRESHOLD_USD ?? '500'
diff --git a/apps/dapp/components/intents/withdraw-confirm.tsx b/apps/dapp/components/intents/withdraw-confirm.tsx
index d50e894d..27bfe704 100644
--- a/apps/dapp/components/intents/withdraw-confirm.tsx
+++ b/apps/dapp/components/intents/withdraw-confirm.tsx
@@ -103,7 +103,9 @@ export function WithdrawConfirm({ withdraw }: { withdraw: Withdraw }): JSX.Eleme
{withdraw.asset ?? ''}
- From the Blend pool back to your wallet. One transaction, one signature.
+ {withdraw.from === 'collateral'
+ ? 'Collateral, from the Blend pool back to your wallet. One transaction, one signature.'
+ : 'From the Blend pool back to your wallet. One transaction, one signature.'}
@@ -114,7 +116,9 @@ export function WithdrawConfirm({ withdraw }: { withdraw: Withdraw }): JSX.Eleme
{emptied
? 'This closes the position. Interest earned right up to the moment it settles comes with it.'
- : `About ${display(withdraw.remaining) ?? 'some'} ${withdraw.asset ?? ''} stays supplied and keeps earning.`}
+ : withdraw.from === 'collateral'
+ ? `About ${display(withdraw.remaining) ?? 'some'} ${withdraw.asset ?? ''} stays as collateral and keeps earning.`
+ : `About ${display(withdraw.remaining) ?? 'some'} ${withdraw.asset ?? ''} stays supplied and keeps earning.`}
diff --git a/apps/dapp/components/layout/app-shell.tsx b/apps/dapp/components/layout/app-shell.tsx
index b5529c66..c476daf8 100644
--- a/apps/dapp/components/layout/app-shell.tsx
+++ b/apps/dapp/components/layout/app-shell.tsx
@@ -8,7 +8,6 @@ import { useEffect, useState, type ReactNode } from 'react'
import { Header } from './header'
import { NetworkMismatchDialog } from './network-mismatch-dialog'
import { Sidebar } from './sidebar'
-import { TradeCapProvider } from './trade-cap'
/**
* The application frame, and where it adapts to a narrow screen.
@@ -23,13 +22,7 @@ import { TradeCapProvider } from './trade-cap'
* stacking rules at each breakpoint, and the version that reads clearly is two
* declarations that each do one thing.
*/
-export function AppShell({
- children,
- tradeCapUsd,
-}: {
- children: ReactNode
- tradeCapUsd?: number | undefined
-}): JSX.Element {
+export function AppShell({ children }: { children: ReactNode }): JSX.Element {
const [menuOpen, setMenuOpen] = useState(false)
const pathname = usePathname()
@@ -73,7 +66,6 @@ export function AppShell({
header and page sit on. See globals.css. */}
setMenuOpen(true)}
menuButton={
- {children}
-
+ {children}
diff --git a/apps/dapp/components/layout/beta-chain-notice.tsx b/apps/dapp/components/layout/beta-chain-notice.tsx
new file mode 100644
index 00000000..aca18c13
--- /dev/null
+++ b/apps/dapp/components/layout/beta-chain-notice.tsx
@@ -0,0 +1,122 @@
+'use client'
+
+import type { ChainSlug } from '@intent/types'
+import { Button, ChainMark } from '@intent/ui'
+import { useEffect, useRef, useState } from 'react'
+
+import {
+ BETA_NOTICE_ADVICE,
+ BETA_NOTICE_POINTS,
+ BETA_RELEASE,
+ betaNoticeIntro,
+ betaNoticeTitle,
+ isBetaChain,
+ rememberBetaNotice,
+ shouldShowBetaNotice,
+} from '../../lib/chain-beta'
+import { useChain } from '../../providers/chain-provider'
+
+/** Solid, since a translucent pill goes muddy on the header's glow. Nothing on a chain not in beta. */
+export function ChainBetaTag({ slug }: { slug: ChainSlug }): JSX.Element | null {
+ if (!isBetaChain(slug)) return null
+ return Beta
+}
+
+function BetaPill({ children }: { children: string }): JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
+
+function BetaNoticeView({
+ slug,
+ chainName,
+ onClose,
+}: {
+ slug: ChainSlug
+ chainName: string
+ onClose: () => void
+}): JSX.Element {
+ const primary = useRef(null)
+
+ useEffect(() => {
+ primary.current?.focus()
+ function onKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape') onClose()
+ // The one control here, so focus can't wander to the page behind.
+ if (e.key === 'Tab') {
+ e.preventDefault()
+ primary.current?.focus()
+ }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ return (
+
+
+
+
+
+ {`Beta ${BETA_RELEASE}`}
+
+
+
+ {betaNoticeTitle(chainName)}
+
+
+
+ {betaNoticeIntro(chainName)}
+
+
+ {BETA_NOTICE_POINTS.map((point) => (
+ {point}
+ ))}
+
+
+ {BETA_NOTICE_ADVICE}
+
+
+
+
+ I understand
+
+
+
+ )
+}
+
+/**
+ * Shown on entering a beta chain, once per chain in a browser. Read after mount, because the server
+ * can't see localStorage and the first render has to match what it sent.
+ */
+export function BetaChainNotice(): JSX.Element | null {
+ const { slug, descriptor } = useChain()
+ const [openFor, setOpenFor] = useState(undefined)
+
+ useEffect(() => {
+ setOpenFor(shouldShowBetaNotice(slug, window.localStorage) ? slug : undefined)
+ }, [slug])
+
+ if (openFor !== slug) return null
+
+ return (
+ {
+ rememberBetaNotice(slug, window.localStorage)
+ setOpenFor(undefined)
+ }}
+ />
+ )
+}
diff --git a/apps/dapp/components/layout/chain-switcher.tsx b/apps/dapp/components/layout/chain-switcher.tsx
index 0d08f90c..408f8628 100644
--- a/apps/dapp/components/layout/chain-switcher.tsx
+++ b/apps/dapp/components/layout/chain-switcher.tsx
@@ -18,12 +18,14 @@ import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useGoToChain } from '../../hooks/use-go-to-chain'
+import { chainLabelWithBeta } from '../../lib/chain-beta'
import { chainTitle } from '../../lib/chain-title'
import { otherStellarNetwork } from '../../lib/other-stellar-network'
import { pathUnderChain } from '../../lib/wallet/chain-path'
import { connectorFor } from '../../lib/wallet/connectors'
import { useWallet } from '../../hooks/use-wallet'
import { ConnectDialog } from '../connect/connect-dialog'
+import { ChainBetaTag } from './beta-chain-notice'
import { useChain } from '../../providers/chain-provider'
/**
@@ -75,7 +77,7 @@ function entriesFor(): Entry[] {
export function ChainSwitcher({
caption,
}: {
- /** A line under the pill, such as the trade cap. The menu opens below it. */
+ /** A line under the pill. The menu opens below it. */
caption?: ReactNode
} = {}): JSX.Element {
const { slug, network, descriptor } = useChain()
@@ -142,7 +144,7 @@ export function ChainSwitcher({
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
- aria-label={`Chain: ${descriptor.networkLabel}. Switch chain`}
+ aria-label={`Chain: ${chainLabelWithBeta(slug, descriptor.networkLabel)}. Switch chain`}
className={cn(
'hover:bg-muted/60 inline-flex items-center gap-2 rounded-full py-1.5 pl-1.5 pr-2.5 text-sm font-semibold transition-colors',
open && 'bg-muted/60'
@@ -159,6 +161,7 @@ export function ChainSwitcher({
/>
{chainTitle(descriptor.name, descriptor.networkLabel)}
+
switchTo(entry)}
- aria-label={entry.label}
+ aria-label={chainLabelWithBeta(entry.slug, entry.label)}
className={rowClass(entry.slug === slug && entry.network === network)}
>
- {chainTitle(entry.name, entry.label)}
+
+ {chainTitle(entry.name, entry.label)}
+
+
{entry.slug === slug && entry.network === network ? (
) : null}
@@ -202,11 +208,14 @@ export function ChainSwitcher({
type="button"
role="menuitem"
onClick={() => switchChain(option)}
- aria-label={d.networkLabel}
+ aria-label={chainLabelWithBeta(option, d.networkLabel)}
className={rowClass(option === slug)}
>
- {chainTitle(d.name, d.networkLabel)}
+
+ {chainTitle(d.name, d.networkLabel)}
+
+
{option === slug ? : null}
)
diff --git a/apps/dapp/components/layout/header.tsx b/apps/dapp/components/layout/header.tsx
index 4437aa14..e86d39d8 100644
--- a/apps/dapp/components/layout/header.tsx
+++ b/apps/dapp/components/layout/header.tsx
@@ -19,14 +19,11 @@ import { ThemeToggle } from './theme-toggle'
*/
export function Header({
menuButton,
- tradeCapUsd,
}: {
/** The drawer trigger, supplied by the shell that owns the drawer's state. */
menuButton?: ReactNode
/** Unused here; the shell passes the handler through `menuButton`. */
onOpenMenu?: () => void
- /** The mainnet per-trade cap, shown beside the network. */
- tradeCapUsd?: number | undefined
}): JSX.Element {
return (
// Clear, so the canvas glow behind it reads through. The page scrolls in
@@ -40,7 +37,7 @@ export function Header({
-
+
}
/>
diff --git a/apps/dapp/components/layout/network-status.tsx b/apps/dapp/components/layout/network-status.tsx
index c633664c..5f6eb8ff 100644
--- a/apps/dapp/components/layout/network-status.tsx
+++ b/apps/dapp/components/layout/network-status.tsx
@@ -22,12 +22,7 @@ const ARC_FAUCET_URL = 'https://faucet.circle.com'
* Everything here is derived from the connection and the active chain, so the
* badge cannot claim a network the wallet is not on.
*/
-export function NetworkStatus({
- tradeCapUsd,
-}: {
- /** The per-trade cap on mainnet, when there is one. Decided on the server. */
- tradeCapUsd?: number | undefined
-}): JSX.Element {
+export function NetworkStatus(): JSX.Element {
const { slug, descriptor } = useChain()
const {
isConnected,
@@ -104,13 +99,7 @@ export function NetworkStatus({
{FAUCET_LABEL[slug]}
)
- ) : isMainnet() || (slug !== 'stellar' && slug !== 'arc') ? (
- tradeCapUsd !== undefined && slug === 'stellar' ? (
-
- Trades up to ${tradeCapUsd}
-
- ) : null
- ) : (
+ ) : isMainnet() || (slug !== 'stellar' && slug !== 'arc') ? null : (
pre-mainnet · rails may change
diff --git a/apps/dapp/components/layout/trade-cap.tsx b/apps/dapp/components/layout/trade-cap.tsx
deleted file mode 100644
index 09d3b762..00000000
--- a/apps/dapp/components/layout/trade-cap.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-'use client'
-
-import { createContext, useContext, type ReactNode } from 'react'
-
-import { tradeCapNote } from '../../lib/trade-cap-copy'
-import { useChain } from '../../providers/chain-provider'
-
-const TradeCapContext = createContext(undefined)
-
-/** Carries the server-decided mainnet cap to any screen that wants to say it. */
-export function TradeCapProvider({
- capUsd,
- children,
-}: {
- capUsd: number | undefined
- children: ReactNode
-}): JSX.Element {
- return {children}
-}
-
-/** One quiet line under a trade input. Renders nothing where there is no cap. */
-export function TradeCapNote(): JSX.Element | null {
- const { slug } = useChain()
- const note = tradeCapNote(useContext(TradeCapContext), slug)
- return note === undefined ? null : {note}
-}
diff --git a/apps/dapp/e2e/beta-chain.spec.ts b/apps/dapp/e2e/beta-chain.spec.ts
new file mode 100644
index 00000000..79fa89a7
--- /dev/null
+++ b/apps/dapp/e2e/beta-chain.spec.ts
@@ -0,0 +1,68 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * A chain in beta says so in its switcher, and warns once on the way in. Stellar does neither. The
+ * shared config marks the notice as seen so other specs aren't blocked, so this starts clean.
+ */
+test.use({
+ storageState: {
+ cookies: [
+ {
+ name: 'e2e-no-wallet-gate',
+ value: '1',
+ domain: 'localhost',
+ path: '/',
+ expires: -1,
+ httpOnly: false,
+ secure: false,
+ sameSite: 'Lax',
+ },
+ ],
+ origins: [],
+ },
+})
+
+test('a beta chain warns once, and carries its tag', async ({ page }) => {
+ await page.goto('/solana/intents')
+
+ const notice = page.getByRole('alertdialog', { name: 'Solana is in beta' })
+ // Long enough for a dev server's first compile of this page.
+ await expect(notice).toBeVisible({ timeout: 45_000 })
+ await expect(notice).toContainText('A transaction can fail')
+ await expect(notice.getByRole('button', { name: 'I understand' })).toBeFocused()
+
+ await notice.getByRole('button', { name: 'I understand' }).click()
+ await expect(notice).toHaveCount(0)
+ await expect(page.getByRole('button', { name: /^Chain: Solana.*, beta/ })).toContainText('Beta')
+
+ // The dev server can abort a navigation while it recompiles.
+ await expect(async () => {
+ await page.reload()
+ }).toPass()
+ await expect(page.getByRole('button', { name: /^Chain: Solana/ })).toBeVisible()
+ await expect(notice).toHaveCount(0)
+})
+
+test('each beta chain warns on its own', async ({ page }) => {
+ await page.goto('/sui/intents')
+ const sui = page.getByRole('alertdialog', { name: 'Sui is in beta' })
+ await expect(sui).toBeVisible({ timeout: 45_000 })
+ await page.keyboard.press('Escape')
+ await expect(sui).toHaveCount(0)
+
+ await page.goto('/near/intents')
+ await expect(page.getByRole('alertdialog', { name: 'NEAR is in beta' })).toBeVisible()
+})
+
+test('Stellar has no notice and no tag', async ({ page }) => {
+ await page.goto('/stellar/intents')
+ const switcher = page.getByRole('button', { name: /^Chain: / })
+ await expect(switcher).toBeVisible()
+ await expect(switcher).not.toContainText('Beta')
+ await expect(page.getByRole('alertdialog')).toHaveCount(0)
+
+ await switcher.click()
+ const menu = page.getByRole('menu', { name: 'Choose a chain' })
+ await expect(menu.getByRole('menuitem', { name: /^Stellar/ })).not.toContainText('Beta')
+ await expect(menu.getByRole('menuitem', { name: /^Monad/ })).toContainText('Beta')
+})
diff --git a/apps/dapp/e2e/beta-notice-seen.ts b/apps/dapp/e2e/beta-notice-seen.ts
new file mode 100644
index 00000000..6ff0c18f
--- /dev/null
+++ b/apps/dapp/e2e/beta-notice-seen.ts
@@ -0,0 +1,15 @@
+import { BETA_RELEASE } from '../lib/chain-beta'
+
+/**
+ * Browser storage with the beta notice already seen on every beta chain, or it would sit over each
+ * page these specs test. `beta-chain.spec.ts` starts without this to test the notice itself.
+ */
+export const BETA_NOTICE_SEEN = [
+ {
+ origin: 'http://localhost:3006',
+ localStorage: ['solana', 'avalanche', 'sui', 'monad', 'near'].map((slug) => ({
+ name: `intent-beta-notice:${BETA_RELEASE}:${slug}`,
+ value: '1',
+ })),
+ },
+]
diff --git a/apps/dapp/e2e/chain-action.spec.ts b/apps/dapp/e2e/chain-action.spec.ts
index e400f2e5..f4fc2bd2 100644
--- a/apps/dapp/e2e/chain-action.spec.ts
+++ b/apps/dapp/e2e/chain-action.spec.ts
@@ -1,11 +1,13 @@
import { expect, test, type Page } from '@playwright/test'
+import { BETA_NOTICE_SEEN } from './beta-notice-seen'
+
/**
* The chat's path on a chain that is not Stellar, from typed words to a signed
* result, with a fake wallet the way a real one announces itself. Both server
* routes are stubbed, so nothing reaches a chain and no agent or price is needed.
*/
-test.use({ storageState: { cookies: [], origins: [] } })
+test.use({ storageState: { cookies: [], origins: BETA_NOTICE_SEEN } })
const ADDRESS = 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg'
const UNSIGNED = btoa('unsigned-transaction')
@@ -171,10 +173,10 @@ test('a transaction with no outcome yet says so, and is not shown as a failure',
test('a refusal from the server is shown as written', async ({ page }) => {
await connectFakeSolanaWallet(page)
await page.route('**/api/chain/solana/prepare', (route) =>
- route.fulfill({ status: 422, json: { error: 'This app caps each trade at $500.' } })
+ route.fulfill({ status: 422, json: { error: 'Nothing here can swap on this chain yet.' } })
)
await say(page, 'Swap 9000 USDC to SOL')
- await expect(page.getByRole('alert').filter({ hasText: 'caps each trade' })).toBeVisible()
+ await expect(page.getByRole('alert').filter({ hasText: 'Nothing here can swap' })).toBeVisible()
})
test('an instruction it cannot read gets told what works, and nothing is built', async ({
diff --git a/apps/dapp/e2e/connect-gate.spec.ts b/apps/dapp/e2e/connect-gate.spec.ts
index b92f0667..7f172fa6 100644
--- a/apps/dapp/e2e/connect-gate.spec.ts
+++ b/apps/dapp/e2e/connect-gate.spec.ts
@@ -1,11 +1,13 @@
import { expect, test, type Page } from '@playwright/test'
+import { BETA_NOTICE_SEEN } from './beta-notice-seen'
+
/**
* The connect page, with fake wallets announced the way real extensions do it:
* the Wallet Standard for Solana and Sui, EIP-6963 for EVM. Nothing here signs or
* reaches a chain.
*/
-test.use({ storageState: { cookies: [], origins: [] } })
+test.use({ storageState: { cookies: [], origins: BETA_NOTICE_SEEN } })
const SOLANA_ADDRESS = 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg'
const SUI_ADDRESS = `0x${'ab'.repeat(32)}`
@@ -134,7 +136,7 @@ test('the picker offers the six chains, and not Arc', async ({ page }) => {
await connectButton(page).click()
const chains = dialog(page).getByRole('button')
for (const name of ['Stellar', 'Solana', 'Avalanche', 'Sui', 'Monad', 'NEAR']) {
- await expect(chains.filter({ hasText: new RegExp(`^${name}$`) })).toBeVisible()
+ await expect(chains.filter({ hasText: new RegExp(`^${name}(Beta)?$`) })).toBeVisible()
}
await expect(chains.filter({ hasText: 'Arc' })).toHaveCount(0)
})
@@ -262,6 +264,46 @@ test('the switcher asks for a wallet on a chain that has none, in place', async
await expect(dialog(page)).toHaveCount(0)
})
+test('toggling between chains warns once on each, then never again', async ({ page }) => {
+ const notice = page.getByRole('alertdialog')
+ const toggleTo = async (chain: RegExp, address: RegExp): Promise => {
+ await page.getByRole('button', { name: /Switch chain/ }).click()
+ await page.getByRole('menuitem', { name: chain }).click()
+ await expect(page).toHaveURL(address)
+ }
+
+ await page.goto('/solana/intents')
+ await page.evaluate(() => {
+ for (const key of Object.keys(window.localStorage)) {
+ if (key.startsWith('intent-beta-notice')) window.localStorage.removeItem(key)
+ }
+ })
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Solana' }).click()
+ await expect(page.getByRole('alertdialog', { name: 'Solana is in beta' })).toBeVisible()
+ await page.getByRole('button', { name: 'I understand' }).click()
+ await expect(notice).toHaveCount(0)
+
+ await page.getByRole('button', { name: /Switch chain/ }).click()
+ await page.getByRole('menuitem', { name: /Sui mainnet/ }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Sui' }).click()
+ await expect(page.getByRole('alertdialog', { name: 'Sui is in beta' })).toBeVisible()
+ await page.getByRole('button', { name: 'I understand' }).click()
+ await expect(notice).toHaveCount(0)
+
+ for (let round = 0; round < 2; round += 1) {
+ await toggleTo(/Solana mainnet/, /\/solana\/intents$/)
+ await expect(notice).toHaveCount(0)
+ await toggleTo(/Sui mainnet/, /\/sui\/intents$/)
+ await expect(notice).toHaveCount(0)
+ }
+
+ await page.reload()
+ await expect(page.getByRole('button', { name: /Switch chain/ })).toBeVisible()
+ await expect(notice).toHaveCount(0)
+})
+
test('the mobile layout keeps the steps in view, without the slides', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/stellar/intents')
diff --git a/apps/dapp/e2e/network-switch.spec.ts b/apps/dapp/e2e/network-switch.spec.ts
index af8919bc..ff12d88a 100644
--- a/apps/dapp/e2e/network-switch.spec.ts
+++ b/apps/dapp/e2e/network-switch.spec.ts
@@ -13,13 +13,11 @@ test.skip(
test('the menu switches the page from testnet to mainnet and back', async ({ page }) => {
await page.goto('/stellar-testnet/apps')
await expect(page.getByRole('button', { name: /^Chain: Stellar testnet/ })).toBeVisible()
- await expect(page.locator('header')).not.toContainText('Trades up to')
await page.getByRole('button', { name: /^Chain: / }).click()
await page.getByRole('menuitem', { name: /Stellar mainnet/ }).click()
await page.waitForURL('**/stellar-mainnet/apps')
await expect(page.getByRole('button', { name: /^Chain: Stellar mainnet/ })).toBeVisible()
- await expect(page.locator('header')).toContainText('Trades up to')
await page.getByRole('button', { name: /^Chain: / }).click()
await page.getByRole('menuitem', { name: /Stellar testnet/ }).click()
diff --git a/apps/dapp/hooks/use-borrow.ts b/apps/dapp/hooks/use-borrow.ts
index 8d4ff674..0137e593 100644
--- a/apps/dapp/hooks/use-borrow.ts
+++ b/apps/dapp/hooks/use-borrow.ts
@@ -38,10 +38,14 @@ export interface BorrowState {
xdr?: string
/** Ticker, for display. */
asset?: string
- /** Base units, or the sentinel when repaying everything. */
+ /** Base units. On a repayment of everything, a little over what is owed: the pool refunds it. */
amount?: string
- /** True when a repayment clears the whole liability. */
+ /** True when a repayment clears the whole liability, by the pool's own simulation. */
everything?: boolean
+ /** What is owed now, in base units, when repaying. */
+ owed?: string
+ /** What a repayment leaves owing, in base units. */
+ remaining?: string
hash?: string
explorerUrl?: string
error?: string
@@ -56,9 +60,9 @@ export interface BorrowState {
}
export interface BorrowRequest {
- /** The reserve's asset, as a contract id. */
- assetId: string
- /** Ticker, for display only. */
+ /** The reserve's asset, as a contract id. A repayment may name a ticker instead. */
+ assetId?: string
+ /** Ticker. Decides the asset only when a repayment gives no contract id. */
symbol: string
/** Base units. Required to borrow; omit to repay everything. */
amount?: string
@@ -97,7 +101,9 @@ export function useBorrow(): Borrow {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account: signer,
- asset: request.assetId,
+ ...(request.assetId !== undefined
+ ? { asset: request.assetId }
+ : { symbol: request.symbol }),
...(request.amount !== undefined ? { amount: request.amount } : {}),
}),
})
@@ -105,6 +111,9 @@ export function useBorrow(): Borrow {
xdr?: string
amount?: string
everything?: boolean
+ owed?: string
+ remaining?: string
+ symbol?: string
error?: string
transient?: boolean
}
@@ -123,9 +132,11 @@ export function useBorrow(): Borrow {
phase: 'review',
direction,
xdr: built.xdr,
- asset: request.symbol,
+ asset: built.symbol ?? request.symbol,
...(built.amount !== undefined ? { amount: built.amount } : {}),
...(built.everything !== undefined ? { everything: built.everything } : {}),
+ ...(built.owed !== undefined ? { owed: built.owed } : {}),
+ ...(built.remaining !== undefined ? { remaining: built.remaining } : {}),
})
} catch {
setState({ phase: 'failed', direction, error: 'Could not reach the network.' })
diff --git a/apps/dapp/hooks/use-collateral.ts b/apps/dapp/hooks/use-collateral.ts
index 9e4318e0..272cee39 100644
--- a/apps/dapp/hooks/use-collateral.ts
+++ b/apps/dapp/hooks/use-collateral.ts
@@ -53,7 +53,7 @@ export interface CollateralRequest {
export interface Collateral extends CollateralState {
/** Builds and simulates, holding it for review. Does not sign. */
prepare: (request: CollateralRequest) => void
- /** Reclaims collateral, returning it to a plain supply balance. */
+ /** Takes collateral back out of the pool, to the wallet. */
reclaim: (request: CollateralRequest) => void
confirm: () => void
reset: () => void
diff --git a/apps/dapp/hooks/use-limit-order.ts b/apps/dapp/hooks/use-limit-order.ts
index dbf7e000..e5296ddd 100644
--- a/apps/dapp/hooks/use-limit-order.ts
+++ b/apps/dapp/hooks/use-limit-order.ts
@@ -23,6 +23,7 @@ export type LimitPhase =
| 'signing'
| 'submitting'
| 'placed'
+ | 'cancelled'
| 'refused'
| 'failed'
@@ -57,8 +58,8 @@ export interface LimitOrder extends LimitOrderState {
prepare: (req: PlaceRequest) => void
/** Signs and submits what `prepare` produced. */
confirm: () => void
- /** Withdraws a resting order, on-chain. */
- cancel: (offerId: string, sellSymbol: string, buySymbol: string) => void
+ /** Withdraws a resting order, on-chain. The route reads the order's assets from the ledger. */
+ cancel: (offerId: string) => void
reset: () => void
}
@@ -140,7 +141,7 @@ export function useLimitOrder(): LimitOrder {
)
const submit = useCallback(
- async (xdr: string, account: string): Promise => {
+ async (xdr: string, account: string, cancelling = false): Promise => {
setState((s) => ({ ...s, phase: 'signing' }))
const signed = await adapter.signTransaction?.({ xdr, address: account })
@@ -151,9 +152,10 @@ export function useLimitOrder(): LimitOrder {
if (!signed.ok) {
// Declining returns to review: the user chose that, and the order is
- // still on the table.
+ // still on the table. A cancellation has nothing to review, and the
+ // order simply stays where it was.
if (signed.reason === 'rejected') {
- setState((s) => ({ ...s, phase: 'review' }))
+ setState((s) => (cancelling ? { phase: 'idle' } : { ...s, phase: 'review' }))
return
}
setState((s) => ({
@@ -185,14 +187,16 @@ export function useLimitOrder(): LimitOrder {
error:
result.reason !== undefined
? FAILURE_MESSAGES[result.reason]
- : 'The order was not placed.',
+ : cancelling
+ ? 'The order was not withdrawn.'
+ : 'The order was not placed.',
}))
return
}
setState((s) => ({
...s,
- phase: 'placed',
+ phase: cancelling ? 'cancelled' : 'placed',
...(result.hash !== undefined ? { hash: result.hash } : {}),
...(result.explorerUrl !== undefined ? { explorerUrl: result.explorerUrl } : {}),
}))
@@ -210,7 +214,7 @@ export function useLimitOrder(): LimitOrder {
}, [state.xdr, address, submit])
const cancel = useCallback(
- (offerId: string, sellSymbol: string, buySymbol: string) => {
+ (offerId: string) => {
if (address === undefined) return
async function run(): Promise {
@@ -222,8 +226,6 @@ export function useLimitOrder(): LimitOrder {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account: address,
- sellSymbol,
- buySymbol,
// Zero size removes the order. Stellar has no delete operation.
amount: '0',
offerId,
@@ -239,7 +241,7 @@ export function useLimitOrder(): LimitOrder {
return
}
- await submit(built.xdr, address as string)
+ await submit(built.xdr, address as string, true)
} catch {
setState({ phase: 'failed', error: 'Could not reach the network.' })
}
diff --git a/apps/dapp/hooks/use-pool-withdraw.ts b/apps/dapp/hooks/use-pool-withdraw.ts
new file mode 100644
index 00000000..7d8849c4
--- /dev/null
+++ b/apps/dapp/hooks/use-pool-withdraw.ts
@@ -0,0 +1,153 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+
+import { useChain } from '../providers/chain-provider'
+import { useWallet } from './use-wallet'
+import { FAILURE_MESSAGES } from '../lib/swap/submit'
+
+/** Taking a share back out of a Stellar liquidity pool: build, review, sign, submit. */
+
+export type PoolWithdrawPhase =
+ | 'idle'
+ | 'building'
+ | 'review'
+ | 'signing'
+ | 'submitting'
+ | 'settled'
+ | 'failed'
+
+export interface PoolWithdrawState {
+ phase: PoolWithdrawPhase
+ poolId?: string
+ xdr?: string
+ /** The least of each asset this accepts. */
+ floors?: { code: string; amount: string }[]
+ explorerUrl?: string
+ error?: string
+}
+
+export interface PoolWithdraw extends PoolWithdrawState {
+ prepare: (poolId: string) => void
+ confirm: () => void
+ reset: () => void
+}
+
+const EMPTY: PoolWithdrawState = { phase: 'idle' }
+
+export function usePoolWithdraw(): PoolWithdraw {
+ const { adapter } = useChain()
+ const { address, isConnected } = useWallet()
+ const [state, setState] = useState(EMPTY)
+
+ const reset = useCallback(() => setState(EMPTY), [])
+
+ const prepare = useCallback(
+ (poolId: string) => {
+ if (!isConnected || address === undefined) {
+ setState({ phase: 'failed', error: 'Connect a wallet to withdraw.' })
+ return
+ }
+
+ async function run(): Promise {
+ setState({ phase: 'building', poolId })
+ try {
+ const res = await fetch('/api/pools/withdraw', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account: address, poolId }),
+ })
+ const built = (await res.json()) as {
+ xdr?: string
+ floors?: { code: string; amount: string }[]
+ error?: string
+ }
+ if (built.xdr === undefined) {
+ setState({ phase: 'failed', error: built.error ?? 'This could not be built.' })
+ return
+ }
+ setState({
+ phase: 'review',
+ poolId,
+ xdr: built.xdr,
+ ...(built.floors !== undefined ? { floors: built.floors } : {}),
+ })
+ } catch {
+ setState({ phase: 'failed', error: 'Could not reach the network.' })
+ }
+ }
+
+ void run()
+ },
+ [address, isConnected]
+ )
+
+ const confirm = useCallback(() => {
+ const envelope = state.xdr
+ if (envelope === undefined || address === undefined) return
+ const signer = address
+
+ async function run(): Promise {
+ setState((s) => ({ ...s, phase: 'signing' }))
+
+ const signed = await adapter.signTransaction?.({ xdr: envelope as string, address: signer })
+ if (signed === undefined) {
+ setState((s) => ({ ...s, phase: 'failed', error: 'This chain cannot withdraw yet.' }))
+ return
+ }
+ if (!signed.ok) {
+ if (signed.reason === 'rejected') {
+ setState((s) => ({ ...s, phase: 'review' }))
+ return
+ }
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error: signed.detail ?? 'The wallet could not sign this withdrawal.',
+ }))
+ return
+ }
+
+ setState((s) => ({ ...s, phase: 'submitting' }))
+
+ const res = await fetch('/api/pools/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signedXdr: signed.signedXdr, account: signer }),
+ })
+ const result = (await res.json()) as {
+ ok?: boolean
+ explorerUrl?: string
+ reason?: keyof typeof FAILURE_MESSAGES
+ }
+
+ if (result.ok !== true) {
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error:
+ result.reason !== undefined
+ ? FAILURE_MESSAGES[result.reason]
+ : 'The withdrawal did not go through.',
+ }))
+ return
+ }
+
+ setState((s) => ({
+ ...s,
+ phase: 'settled',
+ ...(result.explorerUrl !== undefined ? { explorerUrl: result.explorerUrl } : {}),
+ }))
+ }
+
+ void run().catch(() => {
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error: 'Something went wrong signing the withdrawal.',
+ }))
+ })
+ }, [state.xdr, address, adapter])
+
+ return { ...state, prepare, confirm, reset }
+}
diff --git a/apps/dapp/hooks/use-withdraw.ts b/apps/dapp/hooks/use-withdraw.ts
index dcde83e9..16098aef 100644
--- a/apps/dapp/hooks/use-withdraw.ts
+++ b/apps/dapp/hooks/use-withdraw.ts
@@ -39,8 +39,10 @@ export interface WithdrawState {
amount?: string
/** True when this closes the position rather than taking a named amount. */
everything?: boolean
+ /** Which of the pool's two forms this takes from. */
+ from?: 'supply' | 'collateral' | 'both'
/**
- * What the position still holds afterwards, in bTokens.
+ * What stays behind in that form afterwards, in base units of the asset.
*
* Not the sum withdrawn. Zero here means the position will be emptied, which
* is the clearest confirmation a simulation can give.
@@ -52,12 +54,14 @@ export interface WithdrawState {
}
export interface WithdrawRequest {
- /** The reserve's asset, as a contract id. */
- assetId: string
- /** Ticker, for display only. */
+ /** The reserve's asset, as a contract id. Omitted, the ticker names it. */
+ assetId?: string
+ /** Ticker. Decides the asset only when no contract id is given. */
symbol: string
/** Base units, or omitted to withdraw the whole position. */
amount?: string
+ /** Which form to take from. Omitted, the server reads the position and decides. */
+ from?: 'supply' | 'collateral'
}
export interface Withdraw extends WithdrawState {
@@ -93,14 +97,19 @@ export function useWithdraw(): Withdraw {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account: signer,
- asset: request.assetId,
+ ...(request.assetId !== undefined
+ ? { asset: request.assetId }
+ : { symbol: request.symbol }),
...(request.amount !== undefined ? { amount: request.amount } : {}),
+ ...(request.from !== undefined ? { from: request.from } : {}),
}),
})
const built = (await res.json()) as {
xdr?: string
amount?: string
everything?: boolean
+ from?: 'supply' | 'collateral' | 'both'
+ symbol?: string
remaining?: string
error?: string
}
@@ -119,9 +128,10 @@ export function useWithdraw(): Withdraw {
setState({
phase: 'review',
xdr: built.xdr,
- asset: request.symbol,
+ asset: built.symbol ?? request.symbol,
...(built.amount !== undefined ? { amount: built.amount } : {}),
...(built.everything !== undefined ? { everything: built.everything } : {}),
+ ...(built.from !== undefined ? { from: built.from } : {}),
...(built.remaining !== undefined ? { remaining: built.remaining } : {}),
})
} catch {
diff --git a/apps/dapp/lib/__tests__/aave.live.test.ts b/apps/dapp/lib/__tests__/aave.live.test.ts
new file mode 100644
index 00000000..1808c365
--- /dev/null
+++ b/apps/dapp/lib/__tests__/aave.live.test.ts
@@ -0,0 +1,162 @@
+import { http, parseAbi, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ AAVE_AVALANCHE_MARKET,
+ AAVE_MONAD_MARKET,
+ createAaveVenue,
+ NEVERLAND_MARKET,
+ type AaveMarket,
+} from '../chains/evm/aave'
+import { evmReader } from '../chains/evm/client'
+import { sameAddress } from '../chains/evm/submit'
+import type { EvmChain } from '../chains/evm/tokens'
+
+/**
+ * That the pinned contracts still are what they were checked to be. Read-only: nothing signs or
+ * sends. Set the chain's RPC env where the public one rate limits.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 60_000
+// Burned, so it can never move. It holds about 0.05 USDC supplied on Aave on Avalanche.
+const BURN = '0x000000000000000000000000000000000000dEaD'
+
+const RPC: Record = {
+ avalanche: process.env['AVALANCHE_RPC_URL'] ?? 'https://avalanche-c-chain-rpc.publicnode.com',
+ monad: process.env['MONAD_RPC_URL'] ?? 'https://rpc.monad.xyz',
+}
+
+function reader(chain: EvmChain) {
+ return evmReader(chain, http(RPC[chain], { timeout: 20_000, retryCount: 2 }))
+}
+
+const PROVIDER_ABI = parseAbi([
+ 'function getPool() view returns (address)',
+ 'function getPoolDataProvider() view returns (address)',
+ 'function getPriceOracle() view returns (address)',
+])
+const POOL_ABI = parseAbi([
+ 'function ADDRESSES_PROVIDER() view returns (address)',
+ 'function getReservesList() view returns (address[])',
+])
+const ORACLE_ABI = parseAbi(['function BASE_CURRENCY_UNIT() view returns (uint256)'])
+const DECIMALS_ABI = parseAbi(['function decimals() view returns (uint8)'])
+
+describe.skipIf(SKIP).each<[string, AaveMarket]>([
+ ['Aave on Avalanche', AAVE_AVALANCHE_MARKET],
+ ['Aave on Monad', AAVE_MONAD_MARKET],
+ ['Neverland on Monad', NEVERLAND_MARKET],
+])('%s, live', (_name, market) => {
+ const client = reader(market.chain)
+ const provider = market.addressesProvider as Address
+ const pool = market.pool as Address
+
+ it(
+ 'has code at every pinned contract',
+ async () => {
+ for (const address of [market.pool, market.addressesProvider, market.dataProvider]) {
+ const code = await client.getCode({ address: address as Address })
+ expect((code?.length ?? 0) > 2, address).toBe(true)
+ }
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'has a provider and pool that name each other, and the same data provider',
+ async () => {
+ const read = (functionName: T) =>
+ client.readContract({ address: provider, abi: PROVIDER_ABI, functionName })
+ expect(sameAddress(await read('getPool'), market.pool)).toBe(true)
+ expect(sameAddress(await read('getPoolDataProvider'), market.dataProvider)).toBe(true)
+ const named = await client.readContract({
+ address: pool,
+ abi: POOL_ABI,
+ functionName: 'ADDRESSES_PROVIDER',
+ })
+ expect(sameAddress(named, provider)).toBe(true)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'lists every token here as a reserve, with the decimals here',
+ async () => {
+ const reserves = await client.readContract({
+ address: pool,
+ abi: POOL_ABI,
+ functionName: 'getReservesList',
+ })
+ for (const token of market.tokens) {
+ expect(
+ reserves.some((reserve) => sameAddress(reserve, token.id)),
+ token.symbol
+ ).toBe(true)
+ const decimals = await client.readContract({
+ address: token.id as Address,
+ abi: DECIMALS_ABI,
+ functionName: 'decimals',
+ })
+ expect(decimals, token.symbol).toBe(token.decimals)
+ }
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'prices in dollars with eight decimals',
+ async () => {
+ const oracle = await client.readContract({
+ address: provider,
+ abi: PROVIDER_ABI,
+ functionName: 'getPriceOracle',
+ })
+ const unit = await client.readContract({
+ address: oracle,
+ abi: ORACLE_ABI,
+ functionName: 'BASE_CURRENCY_UNIT',
+ })
+ expect(unit).toBe(BigInt(100_000_000))
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads a supply rate for USDC through the venue',
+ async () => {
+ const venue = createAaveVenue(market, client)
+ const quote = await venue.quote!({ kind: 'supply', amount: '1', tokenIn: 'USDC' }, BURN)
+ expect(quote.apyPercent).toBeGreaterThanOrEqual(0)
+ expect(quote.apyPercent).toBeLessThan(100)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'refuses a borrow with no collateral behind it',
+ async () => {
+ const empty = '0x000000000000000000000000000000000000bEEF'
+ const venue = createAaveVenue(market, client)
+ await expect(
+ venue.prepare({ kind: 'borrow', amount: '1', tokenIn: 'USDC' }, empty)
+ ).rejects.toThrow(/Nothing was signed or sent/)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('A withdraw built against the real Pool', () => {
+ it(
+ 'passes the chain’s own trial of the call',
+ async () => {
+ const venue = createAaveVenue(AAVE_AVALANCHE_MARKET, reader('avalanche'))
+ const prepared = await venue.prepare(
+ { kind: 'withdraw', amount: '0.01', tokenIn: 'USDC' },
+ BURN
+ )
+ expect(() => venue.assertSafe(prepared.signing, BURN)).not.toThrow()
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/aave.test.ts b/apps/dapp/lib/__tests__/aave.test.ts
new file mode 100644
index 00000000..c38e15a2
--- /dev/null
+++ b/apps/dapp/lib/__tests__/aave.test.ts
@@ -0,0 +1,904 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+
+import {
+ encodeAbiParameters,
+ encodeFunctionData,
+ erc20Abi,
+ maxUint256,
+ parseAbi,
+ toFunctionSelector,
+ type Address,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ AAVE_AVALANCHE_MARKET,
+ AAVE_MONAD_MARKET,
+ aaveAvalancheVenue,
+ aaveMonadVenue,
+ aaveOraclePriceUsd,
+ createAaveVenue,
+ neverlandVenue,
+ NEVERLAND_MARKET,
+ MIN_HEALTH_FACTOR,
+ type AaveMarket,
+} from '../chains/evm/aave'
+import { aaveListings } from '../chains/evm/aave-listing'
+import type { EvmReaderClient } from '../chains/evm/call'
+import { neverlandListing } from '../chains/evm/neverland-listing'
+import { ChainActionRefused, type ChainActionKind, type SigningPayload } from '../chains/types'
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111'
+const STRANGER = '0x2222222222222222222222222222222222222222'
+const ORACLE = '0x4444444444444444444444444444444444444444'
+const NOT_A_RESERVE = '0x5555555555555555555555555555555555555555'
+const USDC = AAVE_AVALANCHE_MARKET.tokens.find((t) => t.symbol === 'USDC')!.id as Address
+const USDT = AAVE_AVALANCHE_MARKET.tokens.find((t) => t.symbol === 'USDT')!.id as Address
+const POOL = AAVE_AVALANCHE_MARKET.pool as Address
+const AVALANCHE_ID = 43114
+const UNITS = BigInt(10_000_000)
+const RAY = BigInt(10) ** BigInt(27)
+
+const POOL_ABI = parseAbi([
+ 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)',
+ 'function deposit(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)',
+ 'function withdraw(address asset, uint256 amount, address to) returns (uint256)',
+ 'function borrow(address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf)',
+ 'function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) returns (uint256)',
+ 'function setUserEMode(uint8 categoryId)',
+])
+
+function supplyData(asset: Address = USDC, onBehalfOf = ACCOUNT, amount = UNITS, referral = 0) {
+ return encodeFunctionData({
+ abi: POOL_ABI,
+ functionName: 'supply',
+ args: [asset, amount, onBehalfOf as Address, referral],
+ })
+}
+
+function withdrawData(to = ACCOUNT, amount = UNITS) {
+ return encodeFunctionData({
+ abi: POOL_ABI,
+ functionName: 'withdraw',
+ args: [USDC, amount, to as Address],
+ })
+}
+
+function borrowData(onBehalfOf = ACCOUNT, mode = BigInt(2), amount = UNITS) {
+ return encodeFunctionData({
+ abi: POOL_ABI,
+ functionName: 'borrow',
+ args: [USDC, amount, mode, 0, onBehalfOf as Address],
+ })
+}
+
+function repayData(onBehalfOf = ACCOUNT, mode = BigInt(2), amount = UNITS) {
+ return encodeFunctionData({
+ abi: POOL_ABI,
+ functionName: 'repay',
+ args: [USDC, amount, mode, onBehalfOf as Address],
+ })
+}
+
+function approveData(spender: string = POOL, units = UNITS) {
+ return encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spender as Address, units],
+ })
+}
+
+function payload(overrides: Partial> = {}) {
+ return {
+ family: 'evm' as const,
+ chainId: AVALANCHE_ID,
+ to: POOL,
+ data: supplyData(),
+ value: '0',
+ ...overrides,
+ }
+}
+
+/** How the older Aave code Neverland runs says why: Error(string), with a numbered code. */
+function errorString(code: string): string {
+ return `0x08c379a0${encodeAbiParameters([{ type: 'string' }], [code]).slice(2)}`
+}
+
+function refusesAvalanche(signing: SigningPayload, account: string = ACCOUNT) {
+ expect(() => aaveAvalancheVenue.assertSafe(signing, account)).toThrow(ChainActionRefused)
+}
+
+describe('what each market takes', () => {
+ it('is what it lists, whatever else the chain’s table holds', () => {
+ const symbols = (market: { tokens: readonly { symbol: string }[] }): string[] =>
+ market.tokens.map((token) => token.symbol)
+ expect(symbols(AAVE_AVALANCHE_MARKET)).toEqual(['USDC', 'USDT', 'WAVAX', 'WETH.e'])
+ expect(symbols(AAVE_MONAD_MARKET)).toEqual(['USDC', 'USDT0', 'WETH'])
+ expect(symbols(NEVERLAND_MARKET)).toEqual(['USDC', 'USDT0'])
+ })
+})
+
+describe('Aave and Neverland assertSafe', () => {
+ it.each<[string, SigningPayload]>([
+ ['a supply for the person', payload()],
+ ['a supply with an exact approval', payload({ approval: { to: USDC, data: approveData() } })],
+ ['a withdraw to the person', payload({ data: withdrawData() })],
+ [
+ 'a withdraw of everything, to the person',
+ payload({ data: withdrawData(ACCOUNT, maxUint256) }),
+ ],
+ ['a variable borrow for the person', payload({ data: borrowData() })],
+ ['a repay for the person', payload({ data: repayData() })],
+ [
+ 'a repay with an exact approval',
+ payload({ data: repayData(), approval: { to: USDC, data: approveData() } }),
+ ],
+ ])('passes %s', (_name, signing) => {
+ expect(() => aaveAvalancheVenue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', payload({ chainId: 143 })],
+ ['an unknown contract', payload({ to: STRANGER })],
+ ["Neverland's pool on Aave's venue", payload({ to: NEVERLAND_MARKET.pool })],
+ [
+ 'the legacy deposit function',
+ payload({
+ data: encodeFunctionData({
+ abi: POOL_ABI,
+ functionName: 'deposit',
+ args: [USDC, UNITS, ACCOUNT as Address, 0],
+ }),
+ }),
+ ],
+ [
+ 'a function outside the list',
+ payload({
+ data: encodeFunctionData({ abi: POOL_ABI, functionName: 'setUserEMode', args: [1] }),
+ }),
+ ],
+ ['extra calldata', payload({ data: `${supplyData()}00` })],
+ ['native value', payload({ value: '1' })],
+ ['a supply credited to someone else', payload({ data: supplyData(USDC, STRANGER) })],
+ ['a withdraw paid to someone else', payload({ data: withdrawData(STRANGER) })],
+ ['a borrow on someone else’s credit', payload({ data: borrowData(STRANGER) })],
+ ['a repay of someone else’s debt', payload({ data: repayData(STRANGER) })],
+ ['a stable-rate borrow', payload({ data: borrowData(ACCOUNT, BigInt(1)) })],
+ ['a stable-rate repay', payload({ data: repayData(ACCOUNT, BigInt(1)) })],
+ ['a token that is not listed', payload({ data: supplyData(NOT_A_RESERVE) })],
+ ['a referral code', payload({ data: supplyData(USDC, ACCOUNT, UNITS, 7) })],
+ ['a zero amount', payload({ data: supplyData(USDC, ACCOUNT, BigInt(0)) })],
+ ['a repay of everything owed', payload({ data: repayData(ACCOUNT, BigInt(2), maxUint256) })],
+ ['a supply of max-uint', payload({ data: supplyData(USDC, ACCOUNT, maxUint256) })],
+ ['a borrow of max-uint', payload({ data: borrowData(ACCOUNT, BigInt(2), maxUint256) })],
+ [
+ 'a whole withdraw paid to someone else',
+ payload({ data: withdrawData(STRANGER, maxUint256) }),
+ ],
+ [
+ 'an approval beside a whole withdraw',
+ payload({
+ data: withdrawData(ACCOUNT, maxUint256),
+ approval: { to: USDC, data: approveData() },
+ }),
+ ],
+ [
+ 'an unlimited approval',
+ payload({ approval: { to: USDC, data: approveData(POOL, maxUint256) } }),
+ ],
+ ['an approval to a stranger', payload({ approval: { to: USDC, data: approveData(STRANGER) } })],
+ [
+ 'an approval of an unlisted token',
+ payload({ approval: { to: NOT_A_RESERVE, data: approveData() } }),
+ ],
+ [
+ 'an approval of another token than the supply',
+ payload({ approval: { to: USDT, data: approveData() } }),
+ ],
+ [
+ 'an approval above the amount',
+ payload({ approval: { to: USDC, data: approveData(POOL, UNITS + BigInt(1)) } }),
+ ],
+ [
+ 'an approval below the amount',
+ payload({ approval: { to: USDC, data: approveData(POOL, UNITS - BigInt(1)) } }),
+ ],
+ [
+ 'an approval beside a withdraw',
+ payload({ data: withdrawData(), approval: { to: USDC, data: approveData() } }),
+ ],
+ [
+ 'an approval beside a borrow',
+ payload({ data: borrowData(), approval: { to: USDC, data: approveData() } }),
+ ],
+ ])('refuses %s', (_name, signing) => {
+ refusesAvalanche(signing)
+ })
+
+ it('refuses a call built for a different signer', () => {
+ refusesAvalanche(payload(), STRANGER)
+ })
+
+ it('keeps each venue to its own pool', () => {
+ const monadSupply = {
+ family: 'evm' as const,
+ chainId: 143,
+ to: AAVE_MONAD_MARKET.pool,
+ data: supplyData(tokenOf(AAVE_MONAD_MARKET, 'USDC')),
+ value: '0',
+ }
+ expect(() => aaveMonadVenue.assertSafe(monadSupply, ACCOUNT)).not.toThrow()
+ expect(() => neverlandVenue.assertSafe(monadSupply, ACCOUNT)).toThrow(ChainActionRefused)
+ expect(() =>
+ neverlandVenue.assertSafe({ ...monadSupply, to: NEVERLAND_MARKET.pool }, ACCOUNT)
+ ).not.toThrow()
+ })
+})
+
+function tokenOf(market: AaveMarket, symbol: string): Address {
+ return market.tokens.find((t) => t.symbol === symbol)!.id as Address
+}
+
+interface Fixture {
+ frozen?: boolean
+ paused?: boolean
+ active?: boolean
+ borrowingEnabled?: boolean
+ liquidityRate?: bigint
+ variableBorrowRate?: bigint
+ totalAToken?: bigint
+ totalDebt?: bigint
+ supplyCap?: bigint
+ borrowCap?: bigint
+ allowance?: bigint
+ balance?: bigint
+ aTokenBalance?: bigint
+ variableDebt?: bigint
+ usedAsCollateral?: boolean
+ collateralBase?: bigint
+ debtBase?: bigint
+ availableBase?: bigint
+ thresholdBps?: bigint
+ eMode?: bigint
+ reserveThresholdBps?: bigint
+ price?: bigint
+ simulationFails?: boolean
+ revertData?: string
+ failRead?: string
+}
+
+/** Stands in for the chain, with an ordinary healthy account: $1000 of collateral and no debt. */
+function fakeReader(fixture: Fixture = {}) {
+ const calls: { to: string; data: string }[] = []
+ const reads: string[] = []
+ const f = {
+ frozen: false,
+ paused: false,
+ active: true,
+ borrowingEnabled: true,
+ liquidityRate: RAY / BigInt(25),
+ variableBorrowRate: RAY / BigInt(10),
+ totalAToken: BigInt(1_000_000_000_000),
+ totalDebt: BigInt(500_000_000_000),
+ supplyCap: BigInt(0),
+ borrowCap: BigInt(0),
+ allowance: BigInt(0),
+ balance: BigInt(1_000_000_000),
+ aTokenBalance: BigInt(100_000_000),
+ variableDebt: BigInt(0),
+ usedAsCollateral: true,
+ collateralBase: BigInt(100_000_000_000),
+ debtBase: BigInt(0),
+ availableBase: BigInt(75_000_000_000),
+ thresholdBps: BigInt(8000),
+ eMode: BigInt(0),
+ reserveThresholdBps: BigInt(8000),
+ price: BigInt(100_000_000),
+ ...fixture,
+ }
+ const reader = {
+ readContract: async ({ functionName }: { functionName: string }) => {
+ reads.push(functionName)
+ if (f.failRead === functionName) throw new Error('rpc down')
+ switch (functionName) {
+ case 'getReserveConfigurationData':
+ return [
+ BigInt(6),
+ BigInt(7500),
+ f.reserveThresholdBps,
+ BigInt(10500),
+ BigInt(1000),
+ true,
+ f.borrowingEnabled,
+ false,
+ f.active,
+ f.frozen,
+ ]
+ case 'getReserveData':
+ return [
+ BigInt(0),
+ BigInt(0),
+ f.totalAToken,
+ BigInt(0),
+ f.totalDebt,
+ f.liquidityRate,
+ f.variableBorrowRate,
+ BigInt(0),
+ BigInt(0),
+ RAY,
+ RAY,
+ 1,
+ ]
+ case 'getPaused':
+ return f.paused
+ case 'getReserveCaps':
+ return [f.borrowCap, f.supplyCap]
+ case 'getUserReserveData':
+ return [
+ f.aTokenBalance,
+ BigInt(0),
+ f.variableDebt,
+ BigInt(0),
+ f.variableDebt,
+ BigInt(0),
+ f.liquidityRate,
+ 0,
+ f.usedAsCollateral,
+ ]
+ case 'getUserAccountData':
+ return [
+ f.collateralBase,
+ f.debtBase,
+ f.availableBase,
+ f.thresholdBps,
+ BigInt(7500),
+ maxUint256,
+ ]
+ case 'getUserEMode':
+ return f.eMode
+ case 'getPriceOracle':
+ return ORACLE
+ case 'getAssetPrice':
+ return f.price
+ case 'allowance':
+ return f.allowance
+ case 'balanceOf':
+ return f.balance
+ default:
+ throw new Error(`Unexpected read: ${functionName}`)
+ }
+ },
+ multicall: async ({
+ contracts,
+ }: {
+ contracts: { functionName: string }[]
+ }): Promise =>
+ Promise.all(contracts.map((contract) => reader.readContract(contract))),
+ call: async (call: { to: string; data: string }) => {
+ calls.push(call)
+ if (f.revertData !== undefined) {
+ throw Object.assign(new Error('reverted'), { cause: { data: f.revertData } })
+ }
+ if (f.simulationFails === true) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ }
+ return { reader: reader as unknown as EvmReaderClient, calls, reads }
+}
+
+function venueWith(fixture: Fixture = {}, market: AaveMarket = AAVE_AVALANCHE_MARKET) {
+ const fake = fakeReader(fixture)
+ return { venue: createAaveVenue(market, fake.reader), ...fake }
+}
+
+function ask(kind: ChainActionKind, amount = '10', tokenIn = 'USDC') {
+ return { kind, amount, tokenIn }
+}
+
+function evmSigning(signing: SigningPayload) {
+ if (signing.family !== 'evm') throw new Error('Expected an EVM payload.')
+ return signing
+}
+
+describe('supply', () => {
+ it('builds the supply with an exact approval and reads the base rate', async () => {
+ const { venue } = venueWith()
+ const prepared = await venue.prepare(ask('supply'), ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.to).toBe(POOL)
+ expect(signing.data).toBe(supplyData())
+ expect(signing.approval).toEqual({ to: USDC, data: approveData() })
+ expect(prepared.apy?.basePercent).toBeCloseTo(4.08, 2)
+ expect(prepared.apy?.rewardPercent).toBeUndefined()
+ expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' })
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('skips the approval when the allowance covers it, and tries the call', async () => {
+ const { venue, calls } = venueWith({ allowance: UNITS })
+ const prepared = await venue.prepare(ask('supply'), ACCOUNT)
+ expect(evmSigning(prepared.signing).approval).toBeUndefined()
+ expect(calls).toHaveLength(1)
+ })
+
+ it('tells the person the wallet asks twice only when it does', async () => {
+ const twice = await venueWith().venue.prepare(ask('supply'), ACCOUNT)
+ const once = await venueWith({ allowance: UNITS }).venue.prepare(ask('supply'), ACCOUNT)
+ expect(twice.notes.join(' ')).toContain('asks twice')
+ expect(once.notes.join(' ')).not.toContain('asks twice')
+ })
+
+ it('quotes the rate with nothing built or tried', async () => {
+ const { venue, calls, reads } = venueWith()
+ const quote = await venue.quote!(ask('supply'), ACCOUNT)
+ expect(quote.apyPercent).toBeCloseTo(4.08, 2)
+ expect(calls).toHaveLength(0)
+ expect(reads).not.toContain('allowance')
+ })
+
+ it.each<[string, Fixture, RegExp]>([
+ ['a frozen reserve', { frozen: true }, /frozen/],
+ ['a paused reserve', { paused: true }, /paused/],
+ ['an inactive reserve', { active: false }, /isn't open/],
+ [
+ 'a full supply cap',
+ { supplyCap: BigInt(1_000_000), totalAToken: BigInt(1_000_000_000_000) },
+ /supply cap/,
+ ],
+ ['too little in the wallet', { balance: BigInt(1) }, /hold/],
+ ['an unreadable reserve', { failRead: 'getReserveConfigurationData' }, /read/],
+ ['an unreadable balance', { failRead: 'balanceOf' }, /read/],
+ ['an unreadable allowance', { failRead: 'allowance' }, /read/],
+ ])('refuses %s', async (_name, fixture, words) => {
+ const { venue } = venueWith(fixture)
+ const asked = venue.prepare(ask('supply'), ACCOUNT)
+ await expect(asked).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses a token this market does not take', async () => {
+ const { venue } = venueWith()
+ await expect(venue.prepare(ask('supply', '1', 'DOGE'), ACCOUNT)).rejects.toBeInstanceOf(
+ ChainActionRefused
+ )
+ })
+
+ it('refuses an account that is not an address', async () => {
+ const { venue } = venueWith()
+ await expect(venue.prepare(ask('supply'), 'not-an-address')).rejects.toBeInstanceOf(
+ ChainActionRefused
+ )
+ })
+
+ it('refuses a call the chain says would fail', async () => {
+ const { venue } = venueWith({ allowance: UNITS, simulationFails: true })
+ await expect(venue.prepare(ask('supply'), ACCOUNT)).rejects.toBeInstanceOf(ChainActionRefused)
+ })
+})
+
+describe('borrow', () => {
+ it('sets the floor at 1.5', () => {
+ expect(MIN_HEALTH_FACTOR).toBe(1.5)
+ })
+
+ it('builds a variable-rate borrow for the person, tried against the chain', async () => {
+ const { venue, calls } = venueWith()
+ const prepared = await venue.prepare(ask('borrow'), ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.data).toBe(borrowData())
+ expect(signing.approval).toBeUndefined()
+ expect(calls).toHaveLength(1)
+ expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' })
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('says plainly the loan can be liquidated, and shows the health it would leave', async () => {
+ const { venue } = venueWith()
+ const { notes } = await venue.prepare(ask('borrow'), ACCOUNT)
+ const text = notes.join(' ')
+ expect(text).toMatch(/liquidat/i)
+ expect(text).toContain('80.00')
+ expect(text).toMatch(/rate/i)
+ })
+
+ it('reads the health before it builds', async () => {
+ const { venue, reads } = venueWith()
+ await venue.prepare(ask('borrow'), ACCOUNT)
+ expect(reads).toContain('getUserAccountData')
+ expect(reads).toContain('getAssetPrice')
+ })
+
+ it('passes a borrow landing on the floor', async () => {
+ // $1000 of collateral at an 80% threshold, over a $533 loan, is just above 1.5.
+ const { venue } = venueWith({ debtBase: BigInt(0) })
+ const prepared = await venue.prepare(ask('borrow', '533'), ACCOUNT)
+ expect(prepared.kind).toBe('borrow')
+ })
+
+ it('refuses a borrow that would land just under the floor', async () => {
+ const { venue } = venueWith({ debtBase: BigInt(0) })
+ await expect(venue.prepare(ask('borrow', '534'), ACCOUNT)).rejects.toThrow(/1\.5/)
+ })
+
+ it('counts existing debt against the floor', async () => {
+ const { venue } = venueWith({ debtBase: BigInt(50_000_000_000) })
+ await expect(venue.prepare(ask('borrow', '40'), ACCOUNT)).rejects.toThrow(/1\.5/)
+ })
+
+ it.each<[string, Fixture, RegExp]>([
+ ['no collateral', { collateralBase: BigInt(0), availableBase: BigInt(0) }, /collateral/],
+ ['collateral that can back no new loan', { availableBase: BigInt(0) }, /back/],
+ ['a frozen reserve', { frozen: true }, /frozen/],
+ ['a paused reserve', { paused: true }, /paused/],
+ ['borrowing switched off', { borrowingEnabled: false }, /borrow/],
+ ['no price', { price: BigInt(0) }, /price/],
+ ['an unreadable account', { failRead: 'getUserAccountData' }, /read/],
+ ['an unreadable price', { failRead: 'getAssetPrice' }, /read/],
+ ['an unreadable oracle', { failRead: 'getPriceOracle' }, /read/],
+ ['a full borrow cap', { borrowCap: BigInt(1), totalDebt: BigInt(1_000_000) }, /borrow cap/],
+ ['too little free in the pool', { totalAToken: BigInt(1), totalDebt: BigInt(1) }, /free/],
+ ])('refuses %s', async (_name, fixture, words) => {
+ const { venue } = venueWith(fixture)
+ const asked = venue.prepare(ask('borrow'), ACCOUNT)
+ await expect(asked).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ })
+})
+
+describe('repay', () => {
+ it('builds a repay of the person’s own variable debt', async () => {
+ const { venue } = venueWith({ variableDebt: UNITS })
+ const prepared = await venue.prepare(ask('repay'), ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.data).toBe(repayData())
+ expect(signing.approval).toEqual({ to: USDC, data: approveData() })
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('stays open on a frozen reserve', async () => {
+ const { venue } = venueWith({ variableDebt: UNITS, frozen: true })
+ await expect(venue.prepare(ask('repay'), ACCOUNT)).resolves.toMatchObject({ kind: 'repay' })
+ })
+
+ it('says only what is owed is taken when asked to pay more', async () => {
+ const { venue } = venueWith({ variableDebt: BigInt(5_000_000) })
+ const { notes } = await venue.prepare(ask('repay'), ACCOUNT)
+ expect(notes.join(' ')).toContain('5')
+ expect(notes.join(' ')).toMatch(/only what you owe/i)
+ })
+
+ it('lets a wallet holding only what is owed pay more than it holds', async () => {
+ const { venue } = venueWith({ variableDebt: BigInt(5_000_000), balance: BigInt(5_000_000) })
+ await expect(venue.prepare(ask('repay'), ACCOUNT)).resolves.toMatchObject({ kind: 'repay' })
+ })
+
+ it.each<[string, Fixture, RegExp]>([
+ ['no debt', { variableDebt: BigInt(0) }, /owe/],
+ ['too little in the wallet', { variableDebt: UNITS, balance: BigInt(1) }, /hold/],
+ ['a paused reserve', { variableDebt: UNITS, paused: true }, /paused/],
+ ['an unreadable debt', { failRead: 'getUserReserveData' }, /read/],
+ ])('refuses %s', async (_name, fixture, words) => {
+ const { venue } = venueWith(fixture)
+ const asked = venue.prepare(ask('repay'), ACCOUNT)
+ await expect(asked).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ })
+})
+
+describe('withdraw', () => {
+ it('builds a withdraw to the person, with no approval', async () => {
+ const { venue, calls } = venueWith()
+ const prepared = await venue.prepare(ask('withdraw'), ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.data).toBe(withdrawData())
+ expect(signing.approval).toBeUndefined()
+ expect(calls).toHaveLength(1)
+ expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' })
+ })
+
+ it('stays open on a frozen reserve', async () => {
+ const { venue } = venueWith({ frozen: true })
+ await expect(venue.prepare(ask('withdraw'), ACCOUNT)).resolves.toMatchObject({
+ kind: 'withdraw',
+ })
+ })
+
+ it('passes a withdraw from a position with no debt, whatever it does to the collateral', async () => {
+ const { venue } = venueWith({ aTokenBalance: BigInt(100_000_000_000), debtBase: BigInt(0) })
+ await expect(venue.prepare(ask('withdraw', '900'), ACCOUNT)).resolves.toMatchObject({
+ kind: 'withdraw',
+ })
+ })
+
+ it('passes a withdraw of a supply that backs no loan', async () => {
+ const { venue } = venueWith({ debtBase: BigInt(50_000_000_000), usedAsCollateral: false })
+ await expect(venue.prepare(ask('withdraw'), ACCOUNT)).resolves.toMatchObject({
+ kind: 'withdraw',
+ })
+ })
+
+ it.each<[string, Fixture, string, RegExp]>([
+ ['more than is supplied', { aTokenBalance: BigInt(1) }, '10', /supplied/],
+ ['nothing supplied', { aTokenBalance: BigInt(0) }, '10', /supplied/],
+ ['a paused reserve', { paused: true }, '10', /paused/],
+ ['too little free in the pool', { totalAToken: BigInt(1), totalDebt: BigInt(1) }, '10', /free/],
+ [
+ 'taking collateral a loan needs below the floor',
+ {
+ aTokenBalance: BigInt(1_000_000_000),
+ debtBase: BigInt(50_000_000_000),
+ },
+ '400',
+ /1\.5/,
+ ],
+ [
+ 'collateral backing a loan under E-Mode',
+ { debtBase: BigInt(1_000_000_000), eMode: BigInt(1) },
+ '10',
+ /E-Mode/,
+ ],
+ ['an unreadable account', { failRead: 'getUserAccountData' }, '10', /read/],
+ ])('refuses %s', async (_name, fixture, amount, words) => {
+ const { venue } = venueWith(fixture)
+ const asked = venue.prepare(ask('withdraw', amount), ACCOUNT)
+ await expect(asked).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ })
+})
+
+describe('where the money is', () => {
+ it('reads supplied and borrowed with interest, for every token this market lists', async () => {
+ const { venue } = venueWith({ aTokenBalance: BigInt(12_345_678), variableDebt: BigInt(5) })
+ const positions = await venue.positions!(ACCOUNT)
+ expect(positions).toContainEqual({ slot: 'supplied', symbol: 'USDC', amount: '12.345678' })
+ expect(positions).toContainEqual({ slot: 'borrowed', symbol: 'USDC', amount: '0.000005' })
+ })
+
+ it('shows nothing where nothing is there', async () => {
+ const { venue } = venueWith({ aTokenBalance: BigInt(0) })
+ await expect(venue.positions!(ACCOUNT)).resolves.toEqual([])
+ })
+
+ it('throws when it can not read, rather than saying nothing is there', async () => {
+ const { venue } = venueWith({ failRead: 'getUserReserveData' })
+ await expect(venue.positions!(ACCOUNT)).rejects.toThrow()
+ })
+
+ it('refuses an account that is not an address', async () => {
+ const { venue } = venueWith()
+ await expect(venue.positions!('nope')).rejects.toBeInstanceOf(ChainActionRefused)
+ })
+
+ it.each([AAVE_AVALANCHE_MARKET, AAVE_MONAD_MARKET, NEVERLAND_MARKET])(
+ 'reads on every market',
+ async (market) => {
+ const { venue } = venueWith({}, market)
+ await expect(venue.positions!(ACCOUNT)).resolves.not.toEqual([])
+ }
+ )
+})
+
+describe('all of a supply', () => {
+ const whole = { ...ask('withdraw'), wholePosition: true }
+
+ it('uses the Pool’s own max, so interest leaves no dust, and pays only the person', async () => {
+ const { venue } = venueWith({ aTokenBalance: BigInt(100_000_123) })
+ const prepared = await venue.prepare(whole, ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.data).toBe(withdrawData(ACCOUNT, maxUint256))
+ expect(signing.approval).toBeUndefined()
+ expect(prepared.pay).toEqual({ amount: '100.000123', symbol: 'USDC' })
+ expect(prepared.summary).toMatch(/all of your USDC/)
+ expect(prepared.notes.join(' ')).toMatch(/nothing stays behind/)
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(signing, STRANGER)).toThrow(ChainActionRefused)
+ })
+
+ it('stays a number when only part is asked for', async () => {
+ const { venue } = venueWith()
+ const prepared = await venue.prepare(ask('withdraw'), ACCOUNT)
+ expect(evmSigning(prepared.signing).data).toBe(withdrawData())
+ })
+
+ it('stays open on a frozen reserve', async () => {
+ const { venue } = venueWith({ frozen: true })
+ await expect(venue.prepare(whole, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' })
+ })
+
+ it.each<[string, Fixture, RegExp]>([
+ ['nothing there', { aTokenBalance: BigInt(0) }, /no USDC supplied/],
+ ['a paused reserve', { paused: true }, /paused.*stays yours/],
+ [
+ 'less free in the pool than the position',
+ { totalAToken: BigInt(1_000), totalDebt: BigInt(0) },
+ /Only about 0.001 USDC is free/,
+ ],
+ [
+ 'collateral a loan needs',
+ {
+ aTokenBalance: BigInt(1_000_000_000),
+ collateralBase: BigInt(100_000_000_000),
+ debtBase: BigInt(50_000_000_000),
+ },
+ /backs your loan.*Repay some of the loan first/,
+ ],
+ [
+ 'a loan under E-Mode',
+ { debtBase: BigInt(1_000_000_000), eMode: BigInt(1) },
+ /Repay the loan/,
+ ],
+ ['an unreadable account', { failRead: 'getUserAccountData' }, /read/],
+ ])('refuses %s', async (_name, fixture, words) => {
+ const { venue } = venueWith(fixture)
+ const asked = venue.prepare(whole, ACCOUNT)
+ await expect(asked).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it.each<[string, string, RegExp]>([
+ [
+ 'Aave V3’s LTV error',
+ toFunctionSelector('LtvValidationFailed()'),
+ /withdraw that token first/,
+ ],
+ [
+ 'Aave V3’s health error',
+ toFunctionSelector('HealthFactorLowerThanLiquidationThreshold()'),
+ /loan needs it/,
+ ],
+ ['Aave V3’s pause error', toFunctionSelector('ReservePaused()'), /paused/],
+ ['Neverland’s LTV code', errorString('57'), /withdraw that token first/],
+ ['Neverland’s health code', errorString('35'), /loan needs it/],
+ ['Neverland’s pause code', errorString('29'), /paused/],
+ ['a reason nobody knows', errorString('99'), /would fail on the chain/],
+ ])('says why the Pool turned it down, for %s', async (_name, revertData, words) => {
+ const { venue } = venueWith({ revertData })
+ const asked = venue.prepare(whole, ACCOUNT)
+ await expect(asked).rejects.toThrow(words)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+})
+
+describe('all of a loan', () => {
+ const whole = { ...ask('repay'), wholePosition: true }
+ const OWED = BigInt(50_000_000)
+ const NAMED = OWED + OWED / BigInt(10_000) + BigInt(1)
+
+ it('names a little over the debt, with an approval of exactly that, never max-uint', async () => {
+ const { venue } = venueWith({ variableDebt: OWED })
+ const prepared = await venue.prepare(whole, ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.data).toBe(repayData(ACCOUNT, BigInt(2), NAMED))
+ expect(signing.approval).toEqual({ to: USDC, data: approveData(POOL, NAMED) })
+ expect(prepared.pay).toEqual({ amount: '50', symbol: 'USDC' })
+ expect(prepared.summary).toMatch(/all of your USDC loan/)
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('tells the person what the approval covers, and that only the debt is taken', async () => {
+ const { venue } = venueWith({ variableDebt: OWED })
+ const { notes } = await venue.prepare(whole, ACCOUNT)
+ const text = notes.join(' ')
+ expect(text).toContain('50.005001')
+ expect(text).toMatch(/Only what you owe is taken/)
+ expect(text).toMatch(/take exactly 50.005001 USDC/)
+ })
+
+ it('needs no approval where the allowance covers it, and tries the call', async () => {
+ const { venue, calls } = venueWith({ variableDebt: OWED, allowance: NAMED })
+ const prepared = await venue.prepare(whole, ACCOUNT)
+ expect(evmSigning(prepared.signing).approval).toBeUndefined()
+ expect(calls).toHaveLength(1)
+ })
+
+ it('asks for a new approval where one is left short by an earlier repay', async () => {
+ const { venue } = venueWith({ variableDebt: OWED, allowance: BigInt(1_000) })
+ const prepared = await venue.prepare(whole, ACCOUNT)
+ expect(evmSigning(prepared.signing).approval).toEqual({
+ to: USDC,
+ data: approveData(POOL, NAMED),
+ })
+ })
+
+ it('passes a wallet holding what is owed and no more, and says it needs a little over', async () => {
+ const { venue } = venueWith({ variableDebt: OWED, balance: OWED })
+ const { notes } = await venue.prepare(whole, ACCOUNT)
+ expect(notes.join(' ')).toMatch(/needs a little more than the loan/)
+ })
+
+ it('still refuses a tampered margin: an approval above the amount named', () => {
+ const signing = payload({
+ data: repayData(ACCOUNT, BigInt(2), NAMED),
+ approval: { to: USDC, data: approveData(POOL, NAMED + BigInt(1)) },
+ })
+ expect(() => aaveAvalancheVenue.assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it.each<[string, Fixture, RegExp]>([
+ ['nothing owed', { variableDebt: BigInt(0) }, /don't owe/],
+ ['a wallet short of the debt', { variableDebt: OWED, balance: OWED - BigInt(1) }, /hold/],
+ ['a paused reserve', { variableDebt: OWED, paused: true }, /paused/],
+ ['an unreadable debt', { failRead: 'getUserReserveData' }, /read/],
+ ])('refuses %s', async (_name, fixture, words) => {
+ const { venue } = venueWith(fixture)
+ const asked = venue.prepare(whole, ACCOUNT)
+ await expect(asked).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ })
+
+ it('stays open on a frozen reserve', async () => {
+ const { venue } = venueWith({ variableDebt: OWED, frozen: true })
+ await expect(venue.prepare(whole, ACCOUNT)).resolves.toMatchObject({ kind: 'repay' })
+ })
+})
+
+describe('aaveOraclePriceUsd', () => {
+ const usdc = AAVE_AVALANCHE_MARKET.tokens.find((t) => t.symbol === 'USDC')!
+
+ it('reads dollars from the market’s oracle', async () => {
+ const { reader } = fakeReader({ price: BigInt(242_500_000_000) })
+ await expect(aaveOraclePriceUsd(AAVE_AVALANCHE_MARKET, usdc, reader)).resolves.toBe(2425)
+ })
+
+ it.each<[string, Fixture]>([
+ ['a failed read', { failRead: 'getAssetPrice' }],
+ ['a zero price', { price: BigInt(0) }],
+ ])('gives no price on %s', async (_name, fixture) => {
+ const { reader } = fakeReader(fixture)
+ await expect(aaveOraclePriceUsd(AAVE_AVALANCHE_MARKET, usdc, reader)).resolves.toBeUndefined()
+ })
+})
+
+describe('listings and logos', () => {
+ it.each([...aaveListings, neverlandListing].map((listing) => [listing.id, listing] as const))(
+ '%s says it executes, and has its logo',
+ (id, listing) => {
+ expect(listing.integration).toBe('executes')
+ expect(listing.family).toBe('evm')
+ expect(existsSync(join(__dirname, '../../public/images/venues', `${id}.webp`))).toBe(true)
+ }
+ )
+
+ it('lists the ids the venues answer to', () => {
+ expect(aaveListings.map((l) => l.id)).toEqual([aaveAvalancheVenue.id, aaveMonadVenue.id])
+ expect(neverlandListing.id).toBe(neverlandVenue.id)
+ })
+})
+
+describe('notes', () => {
+ it('names admin risk, the freeze and the KelpDAO bad debt on Aave', async () => {
+ const { notes } = await venueWith().venue.prepare(ask('supply'), ACCOUNT)
+ const text = notes.join(' ')
+ expect(text).toMatch(/upgrade/i)
+ expect(text).toMatch(/KelpDAO/)
+ expect(text).toMatch(/frozen|froze/i)
+ })
+
+ it('says Neverland is separate from Aave, and its upgrade delay', async () => {
+ const { notes } = await venueWith({}, NEVERLAND_MARKET).venue.prepare(
+ ask('supply', '10', 'USDC'),
+ ACCOUNT
+ )
+ const text = notes.join(' ')
+ expect(text).toMatch(/not run by Aave|not Aave/i)
+ expect(text).toMatch(/24-hour/)
+ expect(text).not.toMatch(/KelpDAO/)
+ })
+
+ it('tells a supplier how to get out', async () => {
+ const { notes } = await venueWith().venue.prepare(ask('supply'), ACCOUNT)
+ expect(notes.join(' ')).toMatch(/withdraw/i)
+ })
+
+ it('has no em dash in anything the person reads', async () => {
+ const { notes, summary } = await venueWith({ variableDebt: UNITS }).venue.prepare(
+ ask('repay'),
+ ACCOUNT
+ )
+ expect([...notes, summary].join(' ')).not.toContain('—')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/aftermath.live.test.ts b/apps/dapp/lib/__tests__/aftermath.live.test.ts
new file mode 100644
index 00000000..2e32413c
--- /dev/null
+++ b/apps/dapp/lib/__tests__/aftermath.live.test.ts
@@ -0,0 +1,150 @@
+import { bcs } from '@mysten/sui/bcs'
+import { deriveDynamicFieldID, fromBase64, normalizeSuiAddress } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import {
+ AFSUI_TYPE,
+ AFTERMATH_PACKAGE,
+ AFTERMATH_REFERRAL_VAULT,
+ AFTERMATH_SAFE,
+ AFTERMATH_VALIDATOR,
+ AFTERMATH_VAULT,
+ AFTERMATH_VAULT_STATE,
+ aftermathVenue,
+} from '../chains/sui/aftermath'
+import { suiClient } from '../chains/sui/client'
+import { readU64 } from '../chains/sui/haedal'
+import { toBaseUnits } from '../chains/types'
+
+/**
+ * Pinned ids, and real unsigned transactions against mainnet, read-only. Nothing here signs or
+ * sends: each transaction is only built and dry-run, for public addresses whose keys nobody here
+ * has. When an address empties these fail on balance, so swap it for another funded one.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SUI_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const AFSUI_HOLDER = '0xd491566f3add78e8fbc3328590c4d967a91f55dcdd4f84e58f76e08f8bd458eb'
+
+async function simulateOk(transaction: string): Promise {
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(transaction),
+ include: { effects: true },
+ })
+ return result.Transaction?.effects?.status.success === true
+}
+
+describe.skipIf(SKIP)('Aftermath against mainnet', () => {
+ it('stakes to a validator that takes no fee: what it mints is what the rate gives', async () => {
+ const prepared = await aftermathVenue.prepare(
+ { kind: 'stake', amount: '1', tokenIn: 'SUI' },
+ SUI_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => aftermathVenue.assertSafe(prepared.signing, SUI_HOLDER)).not.toThrow()
+ expect(await simulateOk(prepared.signing.transaction)).toBe(true)
+
+ const byRate = await readU64(
+ SUI_HOLDER,
+ `${AFTERMATH_PACKAGE}::staked_sui_vault::sui_to_afsui`,
+ (tx) => [
+ tx.object(AFTERMATH_VAULT),
+ tx.object(AFTERMATH_SAFE),
+ tx.pure.u64(BigInt(1_000_000_000)),
+ ]
+ )
+ expect(prepared.receive?.symbol).toBe('afSUI')
+ expect(Math.abs(Number(prepared.receive?.amount) - Number(byRate) / 1e9)).toBeLessThan(0.00001)
+ }, 60_000)
+
+ it('builds a delayed unstake that passes its check and dry-runs clean', async () => {
+ const prepared = await aftermathVenue.prepare(
+ { kind: 'unstake', amount: '2', tokenIn: 'afSUI' },
+ AFSUI_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => aftermathVenue.assertSafe(prepared.signing, AFSUI_HOLDER)).not.toThrow()
+ expect(await simulateOk(prepared.signing.transaction)).toBe(true)
+ expect(prepared.receive?.symbol).toBe('SUI')
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(2)
+ expect(prepared.notes.join(' ')).toMatch(/in about \d+ hours?/)
+ }, 60_000)
+
+ it('reads afSUI in the wallet to the last unit, and "all" of it dry-runs clean', async () => {
+ const positions = await aftermathVenue.positions?.(AFSUI_HOLDER)
+ console.log(JSON.stringify(positions))
+ const staked = positions?.find((p) => p.slot === 'staked')
+ expect(staked?.symbol).toBe('afSUI')
+
+ const { balance } = await suiClient().core.getBalance({
+ owner: AFSUI_HOLDER,
+ coinType: AFSUI_TYPE,
+ })
+ expect(toBaseUnits(staked?.amount as string, 9)).toBe(balance.balance)
+
+ const prepared = await aftermathVenue.prepare(
+ {
+ kind: 'unstake',
+ amount: staked?.amount as string,
+ wholePosition: true,
+ tokenIn: 'afSUI',
+ },
+ AFSUI_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => aftermathVenue.assertSafe(prepared.signing, AFSUI_HOLDER)).not.toThrow()
+ expect(await simulateOk(prepared.signing.transaction)).toBe(true)
+ }, 60_000)
+
+ it('finds nothing for an empty wallet, and reads the real waiting list without error', async () => {
+ expect(await aftermathVenue.positions?.(normalizeSuiAddress('0x1234'))).toEqual([])
+ }, 30_000)
+
+ /**
+ * The evidence that the wait ends by itself: this delayed unstake was requested by 0x131b16cb on
+ * 28 Oct 2026, and its payout landed in a transaction sent by someone else, Aftermath's crank.
+ */
+ it('a real delayed payout was sent by someone other than the person paid', async () => {
+ const requester = '0x131b16cb8064cdc58665f7821aeee17d018011c0c176b669b93eb39496967d7c'
+ const { Transaction: payout } = await suiClient().core.getTransaction({
+ digest: 'FBktHVRjJWKKZfNGAYaqyg7d9AnU23vZ3Y6o6a38fao3',
+ include: { balanceChanges: true, transaction: true },
+ })
+ expect(payout?.transaction?.sender).not.toBe(requester)
+ const paid = payout?.balanceChanges?.find(
+ (c) => c.address === requester && c.coinType.endsWith('::sui::SUI')
+ )
+ expect(BigInt(paid?.amount ?? '0') > BigInt(0)).toBe(true)
+ }, 30_000)
+
+ it('the waiting list sits where the contract put a real request', () => {
+ // Created at key 0 by the request in transaction 8Fc1GZa4EVASV9uEgTpWKKkQ62RRKLn2p3BVYbSJsDPw.
+ expect(
+ deriveDynamicFieldID(
+ '0xac7ab1a584cf8e19b633d1c868b914080275ea87853eb716a949c778b75f00d9',
+ 'u64',
+ bcs.u64().serialize(0).toBytes()
+ )
+ ).toBe('0x421de7c0d9405782fe95ff04911ffd1678ca82f84d75c54fc8bdc5eda19d10da')
+ })
+
+ it("still matches Aftermath's published addresses, or the ids need a fresh look", async () => {
+ const response = await fetch('https://aftermath.finance/api/addresses')
+ const { staking } = (await response.json()) as {
+ staking: { packages: Record; objects: Record }
+ }
+ expect(staking.packages['lsd']).toBe(AFTERMATH_PACKAGE)
+ expect(staking.objects['stakedSuiVault']).toBe(AFTERMATH_VAULT)
+ expect(staking.objects['safe']).toBe(AFTERMATH_SAFE)
+ expect(staking.objects['referralVault']).toBe(AFTERMATH_REFERRAL_VAULT)
+ expect(staking.objects['stakedSuiVaultState']).toBe(AFTERMATH_VAULT_STATE)
+ expect(staking.objects['aftermathValidator']).toBe(AFTERMATH_VALIDATOR)
+ expect(AFSUI_TYPE.startsWith(`${staking.packages['afsui']}::`)).toBe(true)
+ const { coinMetadata } = await suiClient().core.getCoinMetadata({ coinType: AFSUI_TYPE })
+ expect(coinMetadata?.decimals).toBe(9)
+ const { objects } = await suiClient().core.getObjects({ objectIds: [AFTERMATH_SAFE] })
+ const safe = objects[0]
+ if (safe === undefined || safe instanceof Error) throw new Error('no safe')
+ expect(safe.type).toContain(AFSUI_TYPE)
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/aftermath.test.ts b/apps/dapp/lib/__tests__/aftermath.test.ts
new file mode 100644
index 00000000..142266b7
--- /dev/null
+++ b/apps/dapp/lib/__tests__/aftermath.test.ts
@@ -0,0 +1,539 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Inputs, Transaction } from '@mysten/sui/transactions'
+import { deriveDynamicFieldID, toBase64 } from '@mysten/sui/utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ AFSUI_TYPE,
+ AFTERMATH_PACKAGE,
+ AFTERMATH_REFERRAL_VAULT,
+ AFTERMATH_SAFE,
+ AFTERMATH_TOKENS,
+ AFTERMATH_VALIDATOR,
+ AFTERMATH_VAULT,
+ AFTERMATH_VAULT_STATE,
+ aftermathVenue,
+} from '../chains/sui/aftermath'
+import { ChainActionRefused } from '../chains/types'
+
+const ME = '0x1111111111111111111111111111111111111111111111111111111111111111'
+const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead'
+const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01'
+const DIGEST = '11111111111111111111111111111111'
+
+const mocks = vi.hoisted(() => ({
+ simulate: vi.fn(),
+ getObjects: vi.fn(),
+ getBalance: vi.fn(),
+ systemState: vi.fn(),
+ built: [] as Transaction[],
+ bytes: '',
+}))
+
+vi.mock('../chains/sui/client', async (original) => ({
+ ...(await original()),
+ suiClient: () => ({
+ core: {
+ simulateTransaction: mocks.simulate,
+ getObjects: mocks.getObjects,
+ getBalance: mocks.getBalance,
+ getCurrentSystemState: mocks.systemState,
+ },
+ }),
+ buildUnsigned: async (tx: Transaction) => {
+ mocks.built.push(tx)
+ return mocks.bytes
+ },
+}))
+
+function shared(tx: Transaction, objectId: string) {
+ return tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion: 1, mutable: true }))
+}
+
+async function built(edit: (tx: Transaction) => void, sender: string = ME): Promise {
+ const tx = new Transaction()
+ tx.setSender(sender)
+ tx.setGasOwner(sender)
+ tx.setGasPrice(1000)
+ tx.setGasBudget(10_000_000)
+ tx.setGasPayment([{ objectId: FOREIGN, version: 1, digest: DIGEST }])
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+const VAULT_CALLS = `${AFTERMATH_PACKAGE}::staked_sui_vault`
+
+interface StakeOptions {
+ validator?: string
+ vault?: string
+ safe?: string
+ referral?: string
+ to?: string
+}
+
+function goodStake(tx: Transaction, options: StakeOptions = {}): void {
+ const [sui] = tx.splitCoins(tx.gas, [BigInt(1_000_000_000)])
+ const [minted] = tx.moveCall({
+ target: `${VAULT_CALLS}::request_stake`,
+ arguments: [
+ shared(tx, options.vault ?? AFTERMATH_VAULT),
+ shared(tx, options.safe ?? AFTERMATH_SAFE),
+ shared(tx, '0x5'),
+ shared(tx, options.referral ?? AFTERMATH_REFERRAL_VAULT),
+ sui as never,
+ tx.pure.address(options.validator ?? AFTERMATH_VALIDATOR),
+ ],
+ })
+ tx.transferObjects([minted as never], options.to ?? ME)
+}
+
+function goodUnstake(tx: Transaction, vault: string = AFTERMATH_VAULT): void {
+ tx.moveCall({
+ target: `${VAULT_CALLS}::request_unstake`,
+ arguments: [
+ shared(tx, vault),
+ shared(tx, AFTERMATH_SAFE),
+ tx.object(Inputs.ObjectRef({ objectId: THIEF, version: 1, digest: DIGEST })),
+ ],
+ })
+}
+
+function check(transaction: string, account: string = ME): void {
+ aftermathVenue.assertSafe({ family: 'sui', transaction }, account)
+}
+
+const STATE = {
+ total_rewards_amount: '198164944580303',
+ total_sui_amount: '2944756051147434',
+ protocol_config: {
+ min_staking_threshold: '1000000000',
+ default_unstake_protocol_fee: { total_fee: '50000000000000000' },
+ },
+}
+
+beforeEach(() => {
+ mocks.simulate.mockReset()
+ mocks.getObjects.mockReset()
+ mocks.systemState.mockReset()
+ mocks.built.length = 0
+ mocks.getObjects.mockResolvedValue({ objects: [{ json: STATE }] })
+ mocks.systemState.mockResolvedValue({
+ systemState: {
+ epochStartTimestampMs: String(Date.now() - 20 * 3_600_000),
+ parameters: { epochDurationMs: '86400000' },
+ },
+ })
+})
+
+describe('Aftermath safety on what the wallet would sign', () => {
+ it('passes the stake and the unstake it builds', async () => {
+ await expect(built(goodStake).then(check)).resolves.toBeUndefined()
+ await expect(built(goodUnstake).then(check)).resolves.toBeUndefined()
+ })
+
+ it('refuses the wrong account', async () => {
+ const transaction = await built(goodStake)
+ expect(() => check(transaction, THIEF)).toThrow(/different account/)
+ })
+
+ it('refuses an unknown contract', async () => {
+ const transaction = await built((tx) => {
+ goodUnstake(tx)
+ tx.moveCall({ target: `${FOREIGN}::drain::everything` })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it.each([
+ 'request_unstake_atomic',
+ 'request_unstake_atomic_and_keep',
+ 'request_stake_and_keep',
+ 'request_stake_staked_sui',
+ 'epoch_was_changed',
+ 'update_default_unstake_total_fee',
+ ])('refuses %s, which this flow never uses', async (fn) => {
+ const transaction = await built((tx) => {
+ tx.moveCall({ target: `${VAULT_CALLS}::${fn}` })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it('refuses perps and pools, which are other contracts', async () => {
+ const transaction = await built((tx) => {
+ tx.moveCall({
+ target: '0xf948935b111990c2b604900c9b2eeb8f24dcf9868a45d1ea1653a5f282c10e29::pool::swap',
+ })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it('refuses the same call in the old package', async () => {
+ const transaction = await built((tx) => {
+ tx.moveCall({
+ target:
+ '0x7f6ce7ade63857c4fd16ef7783fed2dfc4d7fb7e40615abdb653030b76aef0c6::staked_sui_vault::request_unstake',
+ })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it('refuses extra steps', async () => {
+ const transaction = await built((tx) => {
+ goodUnstake(tx)
+ tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] })
+ })
+ expect(() => check(transaction)).toThrow(/Publish/)
+ })
+
+ it('refuses afSUI paid to someone else', async () => {
+ const transaction = await built((tx) => goodStake(tx, { to: THIEF }))
+ expect(() => check(transaction)).toThrow(/another address/)
+ })
+
+ it.each([
+ ['vault', { vault: FOREIGN }],
+ ['treasury safe', { safe: FOREIGN }],
+ ['referral vault', { referral: FOREIGN }],
+ ])('refuses a %s other than Aftermath’s', async (_name, options) => {
+ const transaction = await built((tx) => goodStake(tx, options))
+ expect(() => check(transaction)).toThrow(/object/)
+ })
+
+ it('refuses an unstake against another vault', async () => {
+ const transaction = await built((tx) => goodUnstake(tx, FOREIGN))
+ expect(() => check(transaction)).toThrow(/object/)
+ })
+
+ it('refuses a validator it did not choose, which could take a fee of up to 5%', async () => {
+ const transaction = await built((tx) => goodStake(tx, { validator: THIEF }))
+ expect(() => check(transaction)).toThrow(/validator/)
+ })
+
+ it('refuses a call that names a token', async () => {
+ const transaction = await built((tx) => {
+ tx.moveCall({ target: `${VAULT_CALLS}::request_unstake`, typeArguments: ['0x2::sui::SUI'] })
+ })
+ expect(() => check(transaction)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('building an Aftermath stake', () => {
+ it('refuses a token that is not SUI', async () => {
+ await expect(
+ aftermathVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'USDC' }, ME)
+ ).rejects.toThrow(/stakes SUI/)
+ })
+
+ it('refuses under the minimum the vault reports', async () => {
+ await expect(
+ aftermathVenue.prepare({ kind: 'stake', amount: '0.5', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/at least 1 SUI/)
+ expect(mocks.built).toHaveLength(0)
+ })
+
+ it('refuses when the vault cannot be read', async () => {
+ mocks.getObjects.mockResolvedValue({ objects: [{ json: {} }] })
+ await expect(
+ aftermathVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/fees/)
+ })
+
+ it('reads the minted afSUI from the dry run, and says what getting out costs', async () => {
+ mocks.bytes = await built(goodStake)
+ mocks.simulate.mockResolvedValue({
+ Transaction: {
+ balanceChanges: [{ coinType: AFSUI_TYPE, address: ME, amount: '928000000' }],
+ },
+ })
+ const prepared = await aftermathVenue.prepare(
+ { kind: 'stake', amount: '1', tokenIn: 'SUI' },
+ ME
+ )
+ expect(prepared.receive).toEqual({ amount: '0.928', symbol: 'afSUI', approximate: true })
+ expect(prepared.apy).toBeUndefined()
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/about 0.34%/)
+ expect(notes).toMatch(/next Sui epoch/)
+ expect(notes).toMatch(/\$1.14M in April 2026/)
+ expect(notes).toMatch(/single signer/)
+ })
+
+ it('refuses when the dry run credits no afSUI', async () => {
+ mocks.bytes = await built(goodStake)
+ mocks.simulate.mockResolvedValue({ Transaction: { balanceChanges: [] } })
+ await expect(
+ aftermathVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/couldn't confirm/)
+ })
+})
+
+describe('building an Aftermath unstake', () => {
+ const u64 = (n: string) => bcs.u64().serialize(n).toBytes()
+
+ function answers(value: string | undefined, succeeds = true): void {
+ mocks.simulate.mockImplementation(async ({ transaction }: { transaction: unknown }) =>
+ transaction instanceof Transaction
+ ? {
+ Transaction: {},
+ commandResults: [{ returnValues: value === undefined ? [] : [{ bcs: u64(value) }] }],
+ }
+ : succeeds
+ ? { Transaction: {}, commandResults: [] }
+ : { FailedTransaction: {}, commandResults: [] }
+ )
+ }
+
+ it('refuses a token that is not afSUI', async () => {
+ await expect(
+ aftermathVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'sSUI' }, ME)
+ ).rejects.toThrow(/unstakes afSUI/)
+ })
+
+ it('refuses under 1 SUI of value', async () => {
+ answers('999999999')
+ await expect(
+ aftermathVenue.prepare({ kind: 'unstake', amount: '0.9', tokenIn: 'afSUI' }, ME)
+ ).rejects.toThrow(/at least 1 SUI worth/)
+ })
+
+ it('refuses when the value cannot be read', async () => {
+ answers(undefined)
+ await expect(
+ aftermathVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, ME)
+ ).rejects.toThrow(/couldn't read/)
+ })
+
+ it('takes the fee the contract would take, as a real payout did', async () => {
+ mocks.bytes = await built(goodUnstake)
+ // A real delayed payout: 28.144751941 SUI by the rate, 28.050100069 reached the wallet.
+ answers('28144751941')
+ const prepared = await aftermathVenue.prepare(
+ { kind: 'unstake', amount: '26.142139341', tokenIn: 'afSUI' },
+ ME
+ )
+ const shown = Number(prepared.receive?.amount)
+ expect(Math.abs(shown - 28.050100069)).toBeLessThan(0.001)
+ expect(prepared.pay).toEqual({ amount: '26.142139341', symbol: 'afSUI' })
+ expect(prepared.receive?.approximate).toBe(true)
+ })
+
+ it('says the SUI comes later, and how long, from the chain’s epoch', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2000000000')
+ const prepared = await aftermathVenue.prepare(
+ { kind: 'unstake', amount: '2', tokenIn: 'afSUI' },
+ ME
+ )
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/does not come in this transaction/)
+ expect(notes).toMatch(/in about 4 hours/)
+ expect(notes).toMatch(/can't be cancelled/)
+ expect(notes).toMatch(/You sign nothing more/)
+ expect(notes).toMatch(/about 0.34%/)
+ const data = mocks.built[0]?.getData()
+ const targets = data?.commands.flatMap((c) =>
+ c.$kind === 'MoveCall' ? [c.MoveCall.function] : []
+ )
+ expect(targets).toEqual(['request_unstake'])
+ expect(data?.commands.some((c) => c.$kind === 'TransferObjects')).toBe(false)
+ })
+
+ it('refuses when the dry run fails, such as while the vault processes an epoch', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2000000000', false)
+ await expect(
+ aftermathVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, ME)
+ ).rejects.toThrow(/would fail/)
+ })
+
+ it('refuses when the epoch cannot be read', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2000000000')
+ mocks.systemState.mockResolvedValue({
+ systemState: { epochStartTimestampMs: 'x', parameters: { epochDurationMs: '0' } },
+ })
+ await expect(
+ aftermathVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'afSUI' }, ME)
+ ).rejects.toThrow(/epoch/)
+ })
+
+ it('empties the whole position to the last unit', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2000000000')
+ await aftermathVenue.prepare(
+ { kind: 'unstake', amount: '1.234567891', wholePosition: true, tokenIn: 'afSUI' },
+ ME
+ )
+ const coin = mocks.built[0]?.getData().commands[0]
+ expect(coin?.$kind).toBe('$Intent')
+ expect(coin?.$kind === '$Intent' ? coin.$Intent.data['balance'] : undefined).toBe(
+ BigInt('1234567891')
+ )
+ })
+
+ it('says how out of a position too small to unstake', async () => {
+ answers('999999999')
+ await expect(
+ aftermathVenue.prepare({ kind: 'unstake', amount: '0.9', tokenIn: 'afSUI' }, ME)
+ ).rejects.toThrow(/swap 0.9 afSUI to SUI.*Nothing was signed or sent/)
+ })
+
+ it('says plainly there is nothing to claim', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2000000000')
+ const { notes } = await aftermathVenue.prepare(
+ { kind: 'unstake', amount: '2', tokenIn: 'afSUI' },
+ ME
+ )
+ expect(notes.join(' ')).toMatch(/You sign nothing more, and there is nothing to claim/)
+ expect(notes.join(' ')).toMatch(/15 hours/)
+ })
+})
+
+describe('what Aftermath positions report', () => {
+ /** The real table, and the key a real request was stored at: see aftermath.live.test.ts. */
+ const TABLE = '0xac7ab1a584cf8e19b633d1c868b914080275ea87853eb716a949c778b75f00d9'
+ const SLOT_0 = '0x421de7c0d9405782fe95ff04911ffd1678ca82f84d75c54fc8bdc5eda19d10da'
+
+ function vaultWith(records: unknown[], overrides: Record = {}): void {
+ const state = {
+ ...STATE,
+ active_epoch: '1302',
+ pending_unstake_records: { contents: { id: TABLE, size: String(records.length) } },
+ ...overrides,
+ }
+ mocks.getObjects.mockImplementation(async ({ objectIds }: { objectIds: string[] }) =>
+ objectIds[0] === AFTERMATH_VAULT_STATE
+ ? { objects: [{ json: state }] }
+ : {
+ objects: objectIds.map((id) => {
+ const index = Array.from({ length: records.length }, (_, i) =>
+ deriveDynamicFieldID(TABLE, 'u64', bcs.u64().serialize(i).toBytes())
+ ).indexOf(id)
+ return records[index]
+ }),
+ }
+ )
+ }
+
+ const record = (requester: string | null, amount: string) => ({
+ json: { value: { requester, afsui_amount: amount, afsui_id: FOREIGN } },
+ })
+
+ beforeEach(() => {
+ mocks.getBalance.mockReset()
+ mocks.getBalance.mockResolvedValue({ balance: { balance: '0' } })
+ mocks.systemState.mockResolvedValue({
+ systemState: {
+ epoch: '1302',
+ epochStartTimestampMs: String(Date.now() - 20 * 3_600_000),
+ parameters: { epochDurationMs: '86400000' },
+ },
+ })
+ vaultWith([])
+ })
+
+ it('reads an empty wallet with nothing waiting as empty', async () => {
+ expect(await aftermathVenue.positions?.(ME)).toEqual([])
+ })
+
+ it('counts afSUI in the wallet as staked, to the last unit', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: { balance: '1234567891' } })
+ expect(await aftermathVenue.positions?.(ME)).toEqual([
+ { slot: 'staked', symbol: 'afSUI', amount: '1.234567891' },
+ ])
+ expect(mocks.getBalance).toHaveBeenCalledWith({ owner: ME, coinType: AFSUI_TYPE })
+ })
+
+ it('looks where the contract stores a request', async () => {
+ vaultWith([record(ME, '2000000000')])
+ await aftermathVenue.positions?.(ME)
+ expect(mocks.getObjects).toHaveBeenCalledWith(expect.objectContaining({ objectIds: [SLOT_0] }))
+ })
+
+ it('adds up what this account is waiting for, and nobody else’s', async () => {
+ vaultWith([
+ record(ME, '2000000000'),
+ record(THIEF, '9000000000'),
+ record(null, '7000000000'),
+ record(ME, '500000000'),
+ ])
+ expect(await aftermathVenue.positions?.(ME)).toEqual([
+ { slot: 'cooling', symbol: 'afSUI', amount: '2.5', readyInHours: 4 },
+ ])
+ })
+
+ it('shows both when some is in the wallet and some on its way', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: { balance: '1000000000' } })
+ vaultWith([record(ME, '2000000000')])
+ expect((await aftermathVenue.positions?.(ME))?.map((p) => p.slot)).toEqual([
+ 'staked',
+ 'cooling',
+ ])
+ })
+
+ it('shows one hour once the chain has moved past the vault, since it is due', async () => {
+ vaultWith([record(ME, '2000000000')], { active_epoch: '1301' })
+ const found = await aftermathVenue.positions?.(ME)
+ expect(found?.[0]?.readyInHours).toBe(1)
+ })
+
+ it('throws when the balance can not be read', async () => {
+ mocks.getBalance.mockRejectedValue(new Error('rpc down'))
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow('rpc down')
+ })
+
+ it('throws on a balance that is no number', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: { balance: 'x' } })
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow(/unreadable/)
+ })
+
+ it('throws when the vault can not be read', async () => {
+ mocks.getObjects.mockResolvedValue({ objects: [new Error('gone')] })
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow(/unreadable/)
+ })
+
+ it('throws when a waiting request can not be read', async () => {
+ vaultWith([new Error('gone')])
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow('gone')
+ })
+
+ it.each([
+ ['no amount', { json: { value: { requester: ME } } }],
+ ['no requester field', { json: { value: { afsui_amount: '1' } } }],
+ ['no fields', { json: null }],
+ ])('throws on a waiting request with %s', async (_name, broken) => {
+ vaultWith([broken])
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow(/unreadable/)
+ })
+
+ it('throws rather than read part of a list too long to read', async () => {
+ vaultWith([], { pending_unstake_records: { contents: { id: TABLE, size: '501' } } })
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow(/Too many/)
+ })
+
+ it('throws when the epoch can not be read and a figure depends on it', async () => {
+ vaultWith([record(ME, '2000000000')])
+ mocks.systemState.mockRejectedValue(new Error('rpc down'))
+ await expect(aftermathVenue.positions?.(ME)).rejects.toThrow('rpc down')
+ })
+
+ it('throws on a malformed account', async () => {
+ await expect(aftermathVenue.positions?.('nope')).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('after a swap', () => {
+ it('stakes SUI only, and never unstakes', async () => {
+ await expect(
+ aftermathVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SUI' }, ME)
+ ).resolves.toBeUndefined()
+ await expect(
+ aftermathVenue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/only stake/)
+ })
+})
+
+it('lists afSUI with the decimals its metadata gives', () => {
+ expect(AFTERMATH_TOKENS).toEqual([expect.objectContaining({ symbol: 'afSUI', decimals: 9 })])
+})
diff --git a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts
index 118d057b..7fdb813b 100644
--- a/apps/dapp/lib/__tests__/agents-chain-compete.test.ts
+++ b/apps/dapp/lib/__tests__/agents-chain-compete.test.ts
@@ -115,7 +115,7 @@ describe('POST /api/agents/compete on a chain', () => {
expect(given.market.routes).toHaveLength(1)
expect(given.market.routes[0]).toMatchObject({ id: 'ref-1', source: 'Ref' })
expect(given.market.venues).toEqual([{ id: 'ref', name: 'Ref', category: 'dex' }])
- })
+ }, 60_000)
it('still refuses near without a chain action', async () => {
const { POST } = await import('../../app/api/agents/compete/route')
@@ -273,8 +273,6 @@ describe('POST /api/agents/compete on a chain', () => {
['a non-numeric amount', 'lots', 4],
['a zero amount', '0', 4],
['a negative amount', '-3', 4],
- ['an amount over the trade cap', '1000000000', 4],
- ['a token with no live price', '5', null],
]
it.each(cases)('%s', async (_name, amount, price) => {
const quote = vi.fn(async () => ({ receive: { amount: '1', symbol: 'USDC' } }))
diff --git a/apps/dapp/lib/__tests__/aquarius-quoter.live.test.ts b/apps/dapp/lib/__tests__/aquarius-quoter.live.test.ts
index 55d2f03c..a5fe3904 100644
--- a/apps/dapp/lib/__tests__/aquarius-quoter.live.test.ts
+++ b/apps/dapp/lib/__tests__/aquarius-quoter.live.test.ts
@@ -12,9 +12,6 @@ import { createSoroswapQuoter } from '../swap/sources/soroswap-quoter'
* all fail in ways a fixture written from this module's own assumptions
* would never see. Only the real router refuses an unsorted pair or an
* `i128`.
- *
- * The last test is the reason a second router exists at all. It records
- * which venue wins each direction, and the answer is different each way.
*/
const SKIP = process.env['SKIP_LIVE'] === '1'
@@ -100,18 +97,13 @@ describe.skipIf(SKIP)('quoting Aquarius', () => {
}, 30_000)
})
-describe.skipIf(SKIP)('why a second router is worth having', () => {
- it('loses to Soroswap buying XLM and beats it selling XLM', async () => {
- // Predicted from the pools' reserves during planning and confirmed by
- // simulation before this test existed: for 20 USDC Soroswap delivers
- // ~187 XLM to Aquarius's ~43; for 100 XLM Aquarius delivers ~61 USDC to
- // Soroswap's ~10.6. The venues are mirror images, so the direction of
- // the trade decides the venue — and that is what makes the agent
- // competition mean something rather than being a formality with one
- // real answer.
- //
- // Testnet liquidity is synthetic and can be re-seeded, so this pins the
- // *shape* — each venue wins one direction — not the ratio.
+describe.skipIf(SKIP)('Aquarius beside Soroswap', () => {
+ it('each router prices each direction on its own', async () => {
+ // Which venue wins is the market's answer and changes with it: this test
+ // used to require each venue to win one direction, and failed the day one
+ // venue won both. Nothing depended on that. What the race does depend on is
+ // that both routers answer for both directions, in their own name, with an
+ // amount worth comparing.
const aquarius = createAquariusQuoter()
const soroswap = createSoroswapQuoter()
@@ -126,13 +118,13 @@ describe.skipIf(SKIP)('why a second router is worth having', () => {
throw new Error('a venue did not quote')
}
- const buyWinner =
- BigInt(soroBuy.quote.destAmount) > BigInt(aquaBuy.quote.destAmount) ? 'soroswap' : 'aquarius'
- const sellWinner =
- BigInt(aquaSell.quote.destAmount) > BigInt(soroSell.quote.destAmount)
- ? 'aquarius'
- : 'soroswap'
+ expect(aquaBuy.quote.source).toBe('aquarius')
+ expect(aquaSell.quote.source).toBe('aquarius')
+ expect(soroBuy.quote.source).toBe('soroswap')
+ expect(soroSell.quote.source).toBe('soroswap')
- expect(buyWinner).not.toBe(sellWinner)
+ for (const quote of [aquaBuy, soroBuy, aquaSell, soroSell]) {
+ expect(BigInt(quote.quote.destAmount) > BigInt(0)).toBe(true)
+ }
}, 90_000)
})
diff --git a/apps/dapp/lib/__tests__/beefy.live.test.ts b/apps/dapp/lib/__tests__/beefy.live.test.ts
new file mode 100644
index 00000000..412facc4
--- /dev/null
+++ b/apps/dapp/lib/__tests__/beefy.live.test.ts
@@ -0,0 +1,194 @@
+import {
+ encodeAbiParameters,
+ encodeFunctionData,
+ keccak256,
+ pad,
+ parseAbi,
+ toHex,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { BEEFY_MONAD_STRATEGY, BEEFY_MONAD_USDC, BEEFY_MONAD_VAULT } from '../chains/evm/beefy'
+import { evmReader } from '../chains/evm/client'
+
+/**
+ * The real contracts on Monad, read-only: nothing signs or sends. Deposit and withdrawal are only
+ * tried with eth_call, for an empty address given the balance it needs by a state override.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const THROWAWAY = '0x00000000000000000000000000000000000abcde'
+const VAULT = BEEFY_MONAD_VAULT as Address
+const STRATEGY = BEEFY_MONAD_STRATEGY as Address
+const VAULT_IMPLEMENTATION = '91bb303e972995ebe5f593bcddbb6f5ef49dbcbd'
+const AAVE_POOL = '0x69a5F9AD4f96ebf0a0C792dD42a01cC5C0102fef'
+const AAVE_USDC = '0x35a73BAcb179d3740395A3ceCc87FF2e581d6042'
+const SIX_HOURS = BigInt(21_600)
+const STRATEGY_TIMELOCK = '0x09D19184F46A32213DF06b981122e06882B61309'
+const STRATEGY_FACTORY = '0xE6e5732245b3e886DD8897a93D21D29bb652d683'
+const STRATEGY_BEACON = '0xe419fb039022d9b9192ebd7d2ba4fe7ad73d9e8b'
+/** The depth under which this vault would be too thin to offer. It held about $3.01M when reviewed. */
+const MIN_VAULT_USDC = BigInt(500_000) * BigInt(1_000_000)
+// Circle's FiatToken keeps balances at slot 9 and allowances at slot 10; the vault's ERC20 at slot 51.
+const USDC_BALANCE_SLOT = BigInt(9)
+const USDC_ALLOWANCE_SLOT = BigInt(10)
+const SHARE_BALANCE_SLOT = BigInt(51)
+const AMOUNT = BigInt(25_000_000)
+
+const VAULT_ABI = parseAbi([
+ 'function want() view returns (address)',
+ 'function strategy() view returns (address)',
+ 'function approvalDelay() view returns (uint256)',
+ 'function stratCandidate() view returns (address implementation, uint256 proposedTime)',
+ 'function balance() view returns (uint256)',
+ 'function symbol() view returns (string)',
+ 'function decimals() view returns (uint8)',
+ 'function deposit(uint256 amount)',
+ 'function withdraw(uint256 shares)',
+])
+const STRATEGY_ABI = parseAbi([
+ 'function vault() view returns (address)',
+ 'function want() view returns (address)',
+ 'function aToken() view returns (address)',
+ 'function lendingPool() view returns (address)',
+ 'function owner() view returns (address)',
+ 'function paused() view returns (bool)',
+ 'function withdrawFee() view returns (uint256)',
+ 'function getAllFees() view returns (((uint256 total, uint256 beefy, uint256 call, uint256 strategist, string label, bool active) performance, uint256 deposit, uint256 withdraw))',
+])
+const ADMIN_ABI = parseAbi([
+ 'function getMinDelay() view returns (uint256)',
+ 'function owner() view returns (address)',
+])
+
+const reader = evmReader('monad')
+
+function mappingSlot(key: string, slot: bigint): Hex {
+ return keccak256(
+ encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key as Address, slot])
+ )
+}
+
+function word(value: bigint): Hex {
+ return pad(toHex(value), { size: 32 })
+}
+
+describe.skipIf(SKIP)('the pinned Beefy vault on Monad', () => {
+ it('is a minimal proxy of the reviewed implementation, so it can’t be upgraded', async () => {
+ const code = await reader.getCode({ address: VAULT })
+ expect(code).toBe(`0x363d3d373d3d3d363d73${VAULT_IMPLEMENTATION}5af43d82803e903d91602b57fd5bf3`)
+ }, 30_000)
+
+ it('holds USDC with the strategy that was reviewed', async () => {
+ const vault = { address: VAULT, abi: VAULT_ABI }
+ const strategy = { address: STRATEGY, abi: STRATEGY_ABI }
+ expect(await reader.readContract({ ...vault, functionName: 'want' })).toBe(BEEFY_MONAD_USDC)
+ expect(await reader.readContract({ ...vault, functionName: 'strategy' })).toBe(STRATEGY)
+ expect(await reader.readContract({ ...vault, functionName: 'symbol' })).toBe(
+ 'mooAaveV3MonadUSDC'
+ )
+ expect(await reader.readContract({ ...strategy, functionName: 'vault' })).toBe(VAULT)
+ expect(await reader.readContract({ ...strategy, functionName: 'want' })).toBe(BEEFY_MONAD_USDC)
+ expect(await reader.readContract({ ...strategy, functionName: 'lendingPool' })).toBe(AAVE_POOL)
+ expect(await reader.readContract({ ...strategy, functionName: 'aToken' })).toBe(AAVE_USDC)
+ expect(await reader.readContract({ ...strategy, functionName: 'paused' })).toBe(false)
+ }, 30_000)
+
+ it('is not too thin to offer', async () => {
+ const held = await reader.readContract({
+ address: VAULT,
+ abi: VAULT_ABI,
+ functionName: 'balance',
+ })
+ expect(held).toBeGreaterThan(MIN_VAULT_USDC)
+ }, 30_000)
+
+ it('still has the shares and fees the card states', async () => {
+ expect(
+ await reader.readContract({ address: VAULT, abi: VAULT_ABI, functionName: 'decimals' })
+ ).toBe(18)
+ const fees = await reader.readContract({
+ address: STRATEGY,
+ abi: STRATEGY_ABI,
+ functionName: 'getAllFees',
+ })
+ expect(fees.performance.total).toBe(BigInt(95_000_000_000_000_000))
+ expect(fees.deposit).toBe(BigInt(0))
+ expect(fees.withdraw).toBe(BigInt(0))
+ }, 30_000)
+
+ it('still has the 6-hour delays the card states', async () => {
+ expect(
+ await reader.readContract({ address: VAULT, abi: VAULT_ABI, functionName: 'approvalDelay' })
+ ).toBe(SIX_HOURS)
+ expect(
+ await reader.readContract({ address: STRATEGY, abi: STRATEGY_ABI, functionName: 'owner' })
+ ).toBe(STRATEGY_TIMELOCK)
+ expect(
+ await reader.readContract({
+ address: STRATEGY_TIMELOCK,
+ abi: ADMIN_ABI,
+ functionName: 'getMinDelay',
+ })
+ ).toBe(SIX_HOURS)
+ expect(
+ await reader.readContract({
+ address: STRATEGY_BEACON,
+ abi: ADMIN_ABI,
+ functionName: 'owner',
+ })
+ ).toBe(STRATEGY_FACTORY)
+ expect(
+ await reader.readContract({
+ address: STRATEGY_FACTORY,
+ abi: ADMIN_ABI,
+ functionName: 'owner',
+ })
+ ).toBe(STRATEGY_TIMELOCK)
+ }, 30_000)
+
+ it('takes a deposit, tried and not sent', async () => {
+ const allowance = keccak256(
+ encodeAbiParameters(
+ [{ type: 'address' }, { type: 'bytes32' }],
+ [VAULT, mappingSlot(THROWAWAY, USDC_ALLOWANCE_SLOT)]
+ )
+ )
+ await expect(
+ reader.call({
+ account: THROWAWAY,
+ to: VAULT,
+ data: encodeFunctionData({ abi: VAULT_ABI, functionName: 'deposit', args: [AMOUNT] }),
+ stateOverride: [
+ {
+ address: BEEFY_MONAD_USDC,
+ stateDiff: [
+ { slot: mappingSlot(THROWAWAY, USDC_BALANCE_SLOT), value: word(AMOUNT) },
+ { slot: allowance, value: word(AMOUNT) },
+ ],
+ },
+ ],
+ })
+ ).resolves.toBeDefined()
+ }, 30_000)
+
+ it('pays out a withdrawal, tried and not sent', async () => {
+ const data = encodeFunctionData({ abi: VAULT_ABI, functionName: 'withdraw', args: [AMOUNT] })
+ await expect(reader.call({ account: THROWAWAY, to: VAULT, data })).rejects.toThrow()
+ await expect(
+ reader.call({
+ account: THROWAWAY,
+ to: VAULT,
+ data,
+ stateOverride: [
+ {
+ address: VAULT,
+ stateDiff: [{ slot: mappingSlot(THROWAWAY, SHARE_BALANCE_SLOT), value: word(AMOUNT) }],
+ },
+ ],
+ })
+ ).resolves.toBeDefined()
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/beefy.test.ts b/apps/dapp/lib/__tests__/beefy.test.ts
new file mode 100644
index 00000000..f5d2a923
--- /dev/null
+++ b/apps/dapp/lib/__tests__/beefy.test.ts
@@ -0,0 +1,661 @@
+import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, zeroAddress, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ BEEFY_MONAD_STRATEGY,
+ BEEFY_MONAD_USDC,
+ BEEFY_MONAD_VAULT,
+ createBeefyMonadVenue,
+} from '../chains/evm/beefy'
+import { beefyMonadListing } from '../chains/evm/beefy-listing'
+import type { EvmReaderClient } from '../chains/evm/call'
+import type { EvmClient, EvmReceipt } from '../chains/evm/client'
+import { MONAD_TOKENS } from '../chains/evm/tokens'
+import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types'
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111'
+const STRANGER = '0x2222222222222222222222222222222222222222'
+/** Beefy's Morpho USDC vault on Monad: real, but not the one this venue is confirmed for. */
+const OTHER_VAULT = '0xA3d999114BCE3d9771703f06C490e77EE83E589a'
+const USDT0 = '0xe7cd86e13AC4309349F30B3435a9d337750fC82D'
+const MONAD_ID = 143
+const HASH = `0x${'a'.repeat(64)}`
+const FIVE_USDC = BigInt(5_000_000)
+
+const CALLS = parseAbi([
+ 'function deposit(uint256 amount)',
+ 'function withdraw(uint256 shares)',
+ 'function depositAll()',
+ 'function withdrawAll()',
+ 'function earn()',
+ 'function transfer(address to, uint256 amount) returns (bool)',
+ 'function approve(address spender, uint256 amount) returns (bool)',
+])
+
+function deposit(amount = FIVE_USDC): string {
+ return encodeFunctionData({ abi: CALLS, functionName: 'deposit', args: [amount] })
+}
+
+function withdraw(shares = FIVE_USDC): string {
+ return encodeFunctionData({ abi: CALLS, functionName: 'withdraw', args: [shares] })
+}
+
+function approve(units = FIVE_USDC, spender: string = BEEFY_MONAD_VAULT): string {
+ return encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spender as Address, units],
+ })
+}
+
+function payload(overrides: Partial> = {}) {
+ return {
+ family: 'evm' as const,
+ chainId: MONAD_ID,
+ to: BEEFY_MONAD_VAULT,
+ data: deposit(),
+ value: '0',
+ ...overrides,
+ }
+}
+
+interface ChainState {
+ usdc: bigint
+ allowance: bigint
+ shares: bigint
+ vaultBalance: bigint
+ totalSupply: bigint
+ want: string
+ strategy: string
+ candidate: string
+ paused: boolean
+ callFails: boolean
+ readFails: string | undefined
+ aaveActive: boolean
+ aavePaused: boolean
+ aaveSupplied: bigint
+ aaveLent: bigint
+}
+
+/** One share to 1.02 USDC, so shares and USDC differ as they do live. */
+const HEALTHY: ChainState = {
+ usdc: BigInt(100_000_000),
+ allowance: BigInt(0),
+ shares: BigInt(50_000_000),
+ vaultBalance: BigInt(3_060_000_000_000),
+ totalSupply: BigInt(3_000_000_000_000),
+ want: BEEFY_MONAD_USDC,
+ strategy: BEEFY_MONAD_STRATEGY,
+ candidate: zeroAddress,
+ paused: false,
+ callFails: false,
+ readFails: undefined,
+ aaveActive: true,
+ aavePaused: false,
+ aaveSupplied: BigInt(1_000_000_000_000),
+ aaveLent: BigInt(500_000_000_000),
+}
+
+interface FakeRead {
+ address: string
+ functionName: string
+}
+
+function fakeReader(overrides: Partial = {}): EvmReaderClient & { calls: unknown[] } {
+ const state = { ...HEALTHY, ...overrides }
+ const calls: unknown[] = []
+ const reads: Record unknown> = {
+ balanceOf: (address) => (address === BEEFY_MONAD_VAULT ? state.shares : state.usdc),
+ allowance: () => state.allowance,
+ want: () => state.want,
+ strategy: () => state.strategy,
+ stratCandidate: () => [state.candidate, BigInt(0)],
+ balance: () => state.vaultBalance,
+ totalSupply: () => state.totalSupply,
+ paused: () => state.paused,
+ // Aave's market, which this vault lends into. Each stops being readable with `aaveUnread`.
+ getReserveConfigurationData: () => [
+ BigInt(6),
+ BigInt(0),
+ BigInt(7800),
+ BigInt(10500),
+ BigInt(1000),
+ true,
+ true,
+ false,
+ state.aaveActive,
+ false,
+ ],
+ getReserveData: () => [
+ BigInt(0),
+ BigInt(0),
+ state.aaveSupplied,
+ BigInt(0),
+ state.aaveLent,
+ BigInt(0),
+ BigInt(0),
+ BigInt(0),
+ BigInt(0),
+ BigInt(0),
+ BigInt(0),
+ 0,
+ ],
+ getPaused: () => state.aavePaused,
+ getReserveCaps: () => [BigInt(0), BigInt(0)],
+ }
+ const readContract = async ({ address, functionName }: FakeRead) => {
+ if (state.readFails === functionName) throw new Error('rpc down')
+ const read = reads[functionName]
+ if (read === undefined) throw new Error(`unexpected read ${functionName}`)
+ return read(address)
+ }
+ return {
+ calls,
+ readContract,
+ multicall: async ({ contracts }: { contracts: FakeRead[] }) =>
+ Promise.all(contracts.map(readContract)),
+ call: async (request: unknown) => {
+ calls.push(request)
+ if (state.callFails) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ } as unknown as EvmReaderClient & { calls: unknown[] }
+}
+
+const RATES = {
+ 'aavev3-monad-usdc': { totalApy: 0.05, lendingApr: 0.04, vaultApr: 0.0095 },
+}
+
+function fakeFetch(body: unknown = RATES, ok = true): typeof fetch {
+ return (async () => ({ ok, json: async () => body })) as unknown as typeof fetch
+}
+
+function venueWith(
+ overrides: Partial = {},
+ fetcher: typeof fetch = fakeFetch(),
+ client?: EvmClient
+) {
+ return createBeefyMonadVenue(() => fakeReader(overrides), client, fetcher)
+}
+
+function supply(overrides: Partial = {}): ChainActionRequest {
+ return { kind: 'supply', amount: '5', tokenIn: 'USDC', ...overrides }
+}
+
+function takeOut(overrides: Partial = {}): ChainActionRequest {
+ return { kind: 'withdraw', amount: '5.1', tokenIn: 'USDC', ...overrides }
+}
+
+function evm(signing: SigningPayload) {
+ if (signing.family !== 'evm') throw new Error('not evm')
+ return signing
+}
+
+describe('pinned ids', () => {
+ it('uses the Monad token table’s USDC', () => {
+ expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')?.id).toBe(BEEFY_MONAD_USDC)
+ })
+
+ it('lists itself as executing on Monad under the id the logo is named for', () => {
+ expect(beefyMonadListing).toMatchObject({
+ id: 'beefy-monad',
+ family: 'evm',
+ chains: ['Monad'],
+ integration: 'executes',
+ })
+ expect(createBeefyMonadVenue().id).toBe(beefyMonadListing.id)
+ })
+})
+
+describe('assertSafe', () => {
+ const { assertSafe } = venueWith()
+
+ it.each<[string, SigningPayload]>([
+ ['a deposit', payload()],
+ [
+ 'a deposit with its exact approval',
+ payload({ approval: { to: BEEFY_MONAD_USDC, data: approve() } }),
+ ],
+ ['a withdrawal', payload({ data: withdraw() })],
+ [
+ 'withdrawAll, which names no one and pays only the sender',
+ payload({ data: encodeFunctionData({ abi: CALLS, functionName: 'withdrawAll' }) }),
+ ],
+ ])('passes %s', (_label, signing) => {
+ expect(() => assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', payload({ chainId: 43114 })],
+ ['Beefy’s other USDC vault on Monad', payload({ to: OTHER_VAULT })],
+ ['the USDC contract as the target', payload({ to: BEEFY_MONAD_USDC })],
+ ['the strategy as the target', payload({ to: BEEFY_MONAD_STRATEGY })],
+ [
+ 'depositAll, which takes whatever is there',
+ payload({ data: encodeFunctionData({ abi: CALLS, functionName: 'depositAll' }) }),
+ ],
+ [
+ 'withdrawAll with an approval beside it',
+ payload({
+ data: encodeFunctionData({ abi: CALLS, functionName: 'withdrawAll' }),
+ approval: { to: BEEFY_MONAD_USDC, data: approve() },
+ }),
+ ],
+ [
+ 'withdrawAll with extra data',
+ payload({ data: `${encodeFunctionData({ abi: CALLS, functionName: 'withdrawAll' })}00` }),
+ ],
+ ['earn', payload({ data: encodeFunctionData({ abi: CALLS, functionName: 'earn' }) })],
+ [
+ 'sending shares to someone else',
+ payload({
+ data: encodeFunctionData({
+ abi: CALLS,
+ functionName: 'transfer',
+ args: [STRANGER as Address, FIVE_USDC],
+ }),
+ }),
+ ],
+ [
+ 'letting someone else spend the shares',
+ payload({
+ data: encodeFunctionData({
+ abi: CALLS,
+ functionName: 'approve',
+ args: [STRANGER as Address, FIVE_USDC],
+ }),
+ }),
+ ],
+ ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })],
+ ['anything after a deposit’s arguments', payload({ data: `${deposit()}00` })],
+ ['anything after a withdrawal’s arguments', payload({ data: `${withdraw()}00` })],
+ ['an empty deposit', payload({ data: deposit(BigInt(0)) })],
+ ['an empty withdrawal', payload({ data: withdraw(BigInt(0)) })],
+ ['native value', payload({ value: '1' })],
+ [
+ 'an unlimited approval',
+ payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(maxUint256) } }),
+ ],
+ [
+ 'an approval to a stranger',
+ payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(FIVE_USDC, STRANGER) } }),
+ ],
+ ['an approval of another token', payload({ approval: { to: USDT0, data: approve() } })],
+ [
+ 'an approval larger than the deposit',
+ payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(FIVE_USDC + BigInt(1)) } }),
+ ],
+ [
+ 'an approval smaller than the deposit',
+ payload({ approval: { to: BEEFY_MONAD_USDC, data: approve(FIVE_USDC - BigInt(1)) } }),
+ ],
+ [
+ 'an approval on a withdrawal',
+ payload({ data: withdraw(), approval: { to: BEEFY_MONAD_USDC, data: approve() } }),
+ ],
+ [
+ 'a first step that pays someone else',
+ payload({
+ approval: {
+ to: BEEFY_MONAD_USDC,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [STRANGER as Address, FIVE_USDC],
+ }),
+ },
+ }),
+ ],
+ ])('refuses %s', (_label, signing) => {
+ expect(() => assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses an account that is not an address', () => {
+ expect(() => assertSafe(payload(), 'not-an-address')).toThrow(ChainActionRefused)
+ })
+})
+
+describe('supply', () => {
+ it('asks for an exact approval and a deposit of what was typed', async () => {
+ const prepared = await venueWith().prepare(supply(), ACCOUNT)
+ const signing = evm(prepared.signing)
+ expect(signing.to).toBe(BEEFY_MONAD_VAULT)
+ expect(signing.data).toBe(deposit())
+ expect(signing.approval).toEqual({ to: BEEFY_MONAD_USDC, data: approve() })
+ expect(prepared.kind).toBe('supply')
+ expect(prepared.pay).toEqual({ amount: '5', symbol: 'USDC' })
+ expect(prepared.notes.join(' ')).toContain('exactly 5 USDC')
+ })
+
+ it('leaves off the approval when one is in place, and tries the call', async () => {
+ const reader = fakeReader({ allowance: FIVE_USDC })
+ const prepared = await createBeefyMonadVenue(() => reader, undefined, fakeFetch()).prepare(
+ supply(),
+ ACCOUNT
+ )
+ expect(evm(prepared.signing).approval).toBeUndefined()
+ expect(reader.calls).toHaveLength(1)
+ expect(reader.calls[0]).toMatchObject({ account: ACCOUNT, to: BEEFY_MONAD_VAULT })
+ })
+
+ it('passes what it built through its own assertSafe', async () => {
+ const venue = venueWith()
+ const prepared = await venue.prepare(supply(), ACCOUNT)
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('tells the person a withdrawal pays what the strategy returns', async () => {
+ const { notes } = await venueWith().prepare(supply(), ACCOUNT)
+ expect(notes.join(' ')).toContain('pays what the strategy returns')
+ })
+
+ it('says who can change the vault, and how quickly', async () => {
+ const { notes } = await venueWith().prepare(supply(), ACCOUNT)
+ expect(notes.join(' ')).toContain('6-hour')
+ })
+
+ it('keeps Aave’s rate apart from the rewards', async () => {
+ const { apy } = await venueWith().prepare(supply(), ACCOUNT)
+ expect(apy).toEqual({ basePercent: 4, rewardPercent: 1 })
+ })
+
+ it.each<[string, typeof fetch]>([
+ [
+ 'the rate service fails',
+ (async () => {
+ throw new Error('down')
+ }) as unknown as typeof fetch,
+ ],
+ ['it answers an error', fakeFetch(RATES, false)],
+ ['this vault is missing', fakeFetch({})],
+ [
+ 'the rate is not a number',
+ fakeFetch({ 'aavev3-monad-usdc': { totalApy: 'lots', lendingApr: 0.04 } }),
+ ],
+ [
+ 'the rate is negative',
+ fakeFetch({ 'aavev3-monad-usdc': { totalApy: -0.1, lendingApr: 0.04 } }),
+ ],
+ ['the rate is absurd', fakeFetch({ 'aavev3-monad-usdc': { totalApy: 40, lendingApr: 0.04 } })],
+ [
+ 'the parts exceed the whole',
+ fakeFetch({ 'aavev3-monad-usdc': { totalApy: 0.01, lendingApr: 0.04 } }),
+ ],
+ ['the parts are not given', fakeFetch({ 'aavev3-monad-usdc': { totalApy: 0.05 } })],
+ ])('shows no rate when %s, and still builds', async (_label, fetcher) => {
+ const prepared = await venueWith({}, fetcher).prepare(supply(), ACCOUNT)
+ expect(prepared.apy).toBeUndefined()
+ expect(prepared.signing.family).toBe('evm')
+ })
+
+ it.each<[string, Partial]>([
+ ['another token', { tokenIn: 'USDT0' }],
+ ['the native coin', { tokenIn: 'MON' }],
+ ['a token this chain doesn’t list', { tokenIn: 'AUSD' }],
+ ['a zero amount', { amount: '0' }],
+ ['more precision than USDC has', { amount: '1.0000001' }],
+ ['a recipient other than the signer', { recipient: STRANGER }],
+ ])('refuses %s', async (_label, request) => {
+ await expect(venueWith().prepare(supply(request), ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it.each<[string, Partial]>([
+ ['less USDC than asked', { usdc: BigInt(1) }],
+ ['a vault that no longer takes USDC', { want: USDT0 }],
+ ['a strategy nobody reviewed', { strategy: STRANGER }],
+ ['a proposed strategy change', { candidate: STRANGER }],
+ ['a paused strategy', { paused: true }],
+ ['an amount too small to earn a share', { vaultBalance: BigInt(10) ** BigInt(30) }],
+ ['an unreadable vault', { readFails: 'strategy' }],
+ ['an unreadable balance', { readFails: 'balanceOf' }],
+ ['a call that would fail', { allowance: FIVE_USDC, callFails: true }],
+ ])('refuses %s', async (_label, state) => {
+ await expect(venueWith(state).prepare(supply(), ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses an account that is not an address', async () => {
+ await expect(venueWith().prepare(supply(), 'nope')).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses a kind it doesn’t do', async () => {
+ await expect(
+ venueWith().prepare({ kind: 'borrow', amount: '5', tokenIn: 'USDC' }, ACCOUNT)
+ ).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('withdraw', () => {
+ it('burns the shares that pay what was typed, rounded in the person’s favour', async () => {
+ const prepared = await venueWith().prepare(takeOut(), ACCOUNT)
+ expect(evm(prepared.signing).data).toBe(withdraw(FIVE_USDC))
+ expect(prepared.kind).toBe('withdraw')
+ expect(prepared.pay).toEqual({ amount: '5.1', symbol: 'USDC' })
+ expect(prepared.receive).toEqual({ amount: '5.1', symbol: 'USDC', approximate: true })
+
+ const uneven = await venueWith().prepare(takeOut({ amount: '1' }), ACCOUNT)
+ // 1 USDC at 1.02 is 980392.15 shares, so 980393 and never fewer.
+ expect(evm(uneven.signing).data).toBe(withdraw(BigInt(980_393)))
+ })
+
+ it('takes all of a holding at exactly its worth, and refuses one unit more', async () => {
+ // 980392 shares at 1.02 are worth 999999.84 units, so 0.999999 USDC.
+ const shares = BigInt(980_392)
+ const all = await venueWith({ shares }).prepare(takeOut({ amount: '0.999999' }), ACCOUNT)
+ expect(evm(all.signing).data).toBe(withdraw(shares))
+ await expect(venueWith({ shares }).prepare(takeOut({ amount: '1' }), ACCOUNT)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('needs no approval, and tries the call as this person', async () => {
+ const reader = fakeReader()
+ const prepared = await createBeefyMonadVenue(() => reader, undefined, fakeFetch()).prepare(
+ takeOut(),
+ ACCOUNT
+ )
+ expect(evm(prepared.signing).approval).toBeUndefined()
+ expect(reader.calls[0]).toMatchObject({ account: ACCOUNT, to: BEEFY_MONAD_VAULT })
+ })
+
+ it('passes what it built through its own assertSafe', async () => {
+ const venue = venueWith()
+ const prepared = await venue.prepare(takeOut(), ACCOUNT)
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('says a withdrawal pays what the strategy returns', async () => {
+ const { notes } = await venueWith().prepare(takeOut(), ACCOUNT)
+ expect(notes.join(' ')).toContain('pays what the strategy returns')
+ })
+
+ it.each<[string, Partial]>([
+ ['a strategy nobody reviewed', { strategy: STRANGER }],
+ ['a proposed strategy change', { candidate: STRANGER }],
+ ['a paused strategy', { paused: true }],
+ ])('still lets a person out with %s', async (_label, state) => {
+ const prepared = await venueWith(state).prepare(takeOut(), ACCOUNT)
+ expect(prepared.kind).toBe('withdraw')
+ })
+
+ it.each<[string, Partial, Partial]>([
+ ['another token', { tokenIn: 'USDT0' }, {}],
+ ['more than the person holds here', { amount: '51.1' }, {}],
+ ['a person with nothing here', {}, { shares: BigInt(0) }],
+ ['an empty vault', {}, { totalSupply: BigInt(0) }],
+ ['a vault that no longer pays USDC', {}, { want: USDT0 }],
+ ['an unreadable vault', {}, { readFails: 'balance' }],
+ ['a call that would fail', {}, { callFails: true }],
+ ['a recipient other than the signer', { recipient: STRANGER }, {}],
+ ])('refuses %s', async (_label, request, state) => {
+ await expect(venueWith(state).prepare(takeOut(request), ACCOUNT)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('accepts the signer named as the recipient', async () => {
+ const prepared = await venueWith().prepare(takeOut({ recipient: ACCOUNT }), ACCOUNT)
+ expect(prepared.kind).toBe('withdraw')
+ })
+})
+
+describe('quote', () => {
+ it('gives the two rates together for the race, and reads no wallet', async () => {
+ const reader = fakeReader({ readFails: 'balanceOf' })
+ const venue = createBeefyMonadVenue(() => reader, undefined, fakeFetch())
+ await expect(venue.quote?.(supply(), ACCOUNT)).resolves.toEqual({ apyPercent: 5 })
+ expect(reader.calls).toHaveLength(0)
+ })
+
+ it('gives no rate when none can be read', async () => {
+ const venue = venueWith({}, fakeFetch({}))
+ await expect(venue.quote?.(supply(), ACCOUNT)).resolves.toEqual({})
+ })
+
+ it.each<[string, Partial]>([
+ ['a strategy nobody reviewed', { strategy: STRANGER }],
+ ['a paused strategy', { paused: true }],
+ ])('refuses to race with %s', async (_label, state) => {
+ await expect(venueWith(state).quote?.(supply(), ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses another token and another kind', async () => {
+ await expect(venueWith().quote?.(supply({ tokenIn: 'MON' }), ACCOUNT)).rejects.toThrow(
+ ChainActionRefused
+ )
+ await expect(venueWith().quote?.(takeOut(), ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('following a swap', () => {
+ it('takes USDC and nothing else', async () => {
+ const { assertCanFollow } = venueWith()
+ await expect(assertCanFollow?.({ kind: 'supply', tokenIn: 'USDC' }, ACCOUNT)).resolves.toBe(
+ undefined
+ )
+ await expect(assertCanFollow?.({ kind: 'supply', tokenIn: 'MON' }, ACCOUNT)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+})
+
+describe('exits', () => {
+ const WHOLE_DATA = encodeFunctionData({ abi: CALLS, functionName: 'withdrawAll' })
+ const whole = takeOut({ wholePosition: true })
+
+ it('shows what the shares are worth now, in USDC', async () => {
+ await expect(venueWith().positions?.(ACCOUNT)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '51' },
+ ])
+ })
+
+ it('shows nothing with no shares', async () => {
+ await expect(venueWith({ shares: BigInt(0) }).positions?.(ACCOUNT)).resolves.toEqual([])
+ })
+
+ it.each(['balanceOf', 'balance', 'totalSupply'])(
+ 'throws when %s can not be read, rather than saying nothing is there',
+ async (readFails) => {
+ await expect(venueWith({ readFails }).positions?.(ACCOUNT)).rejects.toThrow()
+ }
+ )
+
+ it('uses withdrawAll for all of it, with no approval', async () => {
+ const venue = venueWith()
+ const prepared = await venue.prepare(whole, ACCOUNT)
+ const signing = evm(prepared.signing)
+ expect(signing.data).toBe(WHOLE_DATA)
+ expect(signing.approval).toBeUndefined()
+ expect(prepared.pay).toEqual({ amount: '51', symbol: 'USDC' })
+ expect(prepared.receive).toEqual({ amount: '51', symbol: 'USDC', approximate: true })
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('leaves a part as a part', async () => {
+ const prepared = await venueWith().prepare(takeOut(), ACCOUNT)
+ expect(evm(prepared.signing).data).not.toBe(WHOLE_DATA)
+ })
+
+ it.each<[string, Partial, RegExp]>([
+ ['no shares', { shares: BigInt(0) }, /You have no USDC/],
+ ['Aave having paused USDC', { aavePaused: true }, /has paused USDC/],
+ ['Aave having closed the reserve', { aaveActive: false }, /has paused USDC/],
+ [
+ 'too little free on Aave',
+ { aaveSupplied: BigInt(20_000_000), aaveLent: BigInt(10_000_000) },
+ /only about 10 USDC free/,
+ ],
+ ['a call that would fail', { callFails: true }, /shares are still yours/],
+ ])('refuses all of it with %s', async (_label, state, words) => {
+ const asked = venueWith(state).prepare(whole, ACCOUNT)
+ await expect(asked).rejects.toThrow(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('still lets the chain decide when Aave can not be read', async () => {
+ const prepared = await venueWith({ readFails: 'getReserveData' }).prepare(whole, ACCOUNT)
+ expect(evm(prepared.signing).data).toBe(WHOLE_DATA)
+ })
+
+ it('does not stop for a paused strategy, which only blocks deposits', async () => {
+ await expect(venueWith({ paused: true }).prepare(whole, ACCOUNT)).resolves.toMatchObject({
+ kind: 'withdraw',
+ })
+ })
+})
+
+function fakeClient(
+ receipt: Partial,
+ sent: { input: string; value: bigint } = { input: deposit(), value: BigInt(0) }
+): EvmClient {
+ return {
+ tokenBalance: async () => BigInt(0),
+ nativeBalance: async () => BigInt(0),
+ waitForReceipt: async () =>
+ ({
+ status: 'success',
+ from: ACCOUNT,
+ to: BEEFY_MONAD_VAULT,
+ logs: [],
+ ...receipt,
+ }) as EvmReceipt,
+ transaction: async () => sent,
+ }
+}
+
+describe('submit', () => {
+ const signed = {
+ account: ACCOUNT,
+ venueId: 'beefy-monad',
+ signed: { family: 'evm' as const, hash: HASH },
+ }
+
+ function submitWith(client: EvmClient) {
+ return createBeefyMonadVenue(() => fakeReader(), client, fakeFetch()).submit?.(signed)
+ }
+
+ it('settles a landed deposit from this person', async () => {
+ await expect(submitWith(fakeClient({}))).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('says funds did not move for a proven revert of this very call', async () => {
+ const result = await submitWith(fakeClient({ status: 'reverted' }))
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it.each<[string, EvmClient]>([
+ ['the wrong signer', fakeClient({ from: STRANGER })],
+ ['another contract', fakeClient({ to: OTHER_VAULT })],
+ [
+ 'a call to something else on the vault',
+ fakeClient(
+ {},
+ { input: encodeFunctionData({ abi: CALLS, functionName: 'earn' }), value: BigInt(0) }
+ ),
+ ],
+ ])('stays pending for %s, since the money may have moved', async (_label, client) => {
+ await expect(submitWith(client)).resolves.toMatchObject({ ok: false, pending: true })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/blend-borrow.live.test.ts b/apps/dapp/lib/__tests__/blend-borrow.live.test.ts
index f667f47b..537bc4b8 100644
--- a/apps/dapp/lib/__tests__/blend-borrow.live.test.ts
+++ b/apps/dapp/lib/__tests__/blend-borrow.live.test.ts
@@ -100,18 +100,15 @@ describe.skipIf(SKIP)('repaying', () => {
}
}, 90_000)
- it('repays everything when no amount is named', async () => {
- // The sentinel matters more for a debt than for a supply: interest makes a
- // figure read a moment ago too *small*, so an exact repayment leaves a
- // remainder that keeps growing and keeps the position liquidatable.
- const built = await buildBlendRepay({ account: ME, asset: WBTC })
-
- expect(built.everything).toBe(true)
- expect(built.amount).toBe('170141183460469231731687303715884105727')
+ it('takes no sentinel: a repayment always names an amount', async () => {
+ // The pool takes the whole amount from the wallet before refunding, so
+ // `i128::MAX` is refused by the token. This test used to pin that sentinel
+ // without simulating it, which is how "Repay all" shipped broken.
+ await expect(buildBlendRepay({ account: ME, asset: WBTC })).rejects.toThrow(/needs an amount/)
}, 60_000)
it('pays down the signing account and no other', async () => {
- const built = await buildBlendRepay({ account: ME, asset: blendXlm() })
+ const built = await buildBlendRepay({ account: ME, asset: blendXlm(), amount: '10000000' })
expect(() => assertSelfRepay(built.xdr, ME)).not.toThrow()
expect(() => assertSelfRepay(built.xdr, STRANGER)).toThrow(/refusing to sign/)
diff --git a/apps/dapp/lib/__tests__/blend-exit.test.ts b/apps/dapp/lib/__tests__/blend-exit.test.ts
new file mode 100644
index 00000000..954a63e8
--- /dev/null
+++ b/apps/dapp/lib/__tests__/blend-exit.test.ts
@@ -0,0 +1,370 @@
+import {
+ Account,
+ Address,
+ BASE_FEE,
+ Contract,
+ Networks,
+ TransactionBuilder,
+ nativeToScVal,
+ xdr,
+} from '@stellar/stellar-sdk'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ assertSelfBorrow,
+ assertSelfCollateralWithdraw,
+ assertSelfRepay,
+ assertSelfWithdraw,
+ buildBlendRepay,
+ buildBlendWithdraw,
+ buildBlendWithdrawEverywhere,
+} from '../lend/blend-client'
+import {
+ findDebt,
+ planRepayAll,
+ planWithdraw,
+ repayAllAmount,
+ spendableXlm,
+ worthOf,
+} from '../lend/exit'
+import { explainExitError } from '../lend/pool-errors'
+import type { BlendBalance, BlendPositions } from '../lend/position'
+import { blendReserveFor, blendSymbol, suppliableReserve } from '../lend/reserves'
+import { blendPool } from '../swap/contract-registry'
+
+const ME = 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA'
+const STRANGER = 'GCYQ3NXJHGD7P36OVKII6GKVLAENQ6ZETYOBAPZTI4R6ZWUU6QLHVVUX'
+const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
+const OTHER_CONTRACT = 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA'
+const MAX = '170141183460469231731687303715884105727'
+
+function balance(symbol: string, amount: string, reserveIndex = 0): BlendBalance {
+ return {
+ assetId: `C${symbol}`,
+ reserveIndex,
+ symbol,
+ tokens: amount,
+ amount,
+ display: (Number(amount) / 1e7).toFixed(7),
+ }
+}
+
+function positions(partial: Partial): BlendPositions {
+ return {
+ supplied: [],
+ collateral: [],
+ borrowed: [],
+ health: { collateralBase: 0, liabilityBase: 0, healthFactor: undefined, liquidatable: false },
+ liquidationPrice: undefined,
+ ...partial,
+ }
+}
+
+const horizon = (async () =>
+ new Response(JSON.stringify({ sequence: '1' }), { status: 200 })) as unknown as typeof fetch
+
+function poolCall(options: {
+ pool?: string
+ fn?: string
+ to?: string
+ requestType?: number
+ amount?: string
+}): string {
+ const request = nativeToScVal(
+ {
+ address: new Address(XLM),
+ amount: BigInt(options.amount ?? '10000000'),
+ request_type: options.requestType ?? 1,
+ },
+ {
+ type: {
+ address: ['symbol', 'address'],
+ amount: ['symbol', 'i128'],
+ request_type: ['symbol', 'u32'],
+ },
+ }
+ )
+ return new TransactionBuilder(new Account(ME, '1'), {
+ fee: BASE_FEE,
+ networkPassphrase: Networks.TESTNET,
+ })
+ .addOperation(
+ new Contract(options.pool ?? blendPool()).call(
+ options.fn ?? 'submit',
+ new Address(ME).toScVal(),
+ new Address(ME).toScVal(),
+ new Address(options.to ?? ME).toScVal(),
+ xdr.ScVal.scvVec([request])
+ )
+ )
+ .setTimeout(60)
+ .build()
+ .toXDR()
+}
+
+afterEach(() => {
+ vi.doUnmock('../lend/position')
+ vi.resetModules()
+})
+
+describe('choosing where a withdrawal comes from', () => {
+ it('takes from the plain supply when it covers the amount', () => {
+ const plan = planWithdraw(
+ positions({ supplied: [balance('XLM', '500000000')], collateral: [balance('XLM', '900')] }),
+ { symbol: 'xlm', amount: '250000000' }
+ )
+ expect(plan).toMatchObject({ from: 'supply', amount: '250000000', symbol: 'XLM' })
+ })
+
+ it('takes from collateral when that is where the money is', () => {
+ const plan = planWithdraw(positions({ collateral: [balance('USDC', '500000000')] }), {
+ symbol: 'USDC',
+ amount: '250000000',
+ })
+ expect(plan).toMatchObject({ from: 'collateral', amount: '250000000' })
+ })
+
+ it('empties both forms when all of an asset is asked for', () => {
+ const plan = planWithdraw(
+ positions({ supplied: [balance('XLM', '5')], collateral: [balance('XLM', '9')] }),
+ { symbol: 'XLM' }
+ )
+ expect(plan).toMatchObject({ from: 'both' })
+ expect('amount' in plan).toBe(false)
+ })
+
+ it('refuses in plain words when there is nothing there', () => {
+ const plan = planWithdraw(positions({ supplied: [balance('USDC', '5')] }), { symbol: 'XLM' })
+ expect(plan).toMatchObject({
+ refusal: expect.stringMatching(/no XLM in Blend.*Nothing was signed or sent\.$/),
+ })
+ })
+
+ it('refuses more than is held rather than quietly taking less', () => {
+ const plan = planWithdraw(positions({ supplied: [balance('XLM', '100000000')] }), {
+ symbol: 'XLM',
+ amount: '250000000',
+ })
+ expect(plan).toMatchObject({ refusal: expect.stringMatching(/less than you asked/) })
+ })
+
+ it('says so when an amount is more than either form holds, though not more than both', () => {
+ const plan = planWithdraw(
+ positions({
+ supplied: [balance('XLM', '100000000')],
+ collateral: [balance('XLM', '100000000')],
+ }),
+ { symbol: 'XLM', amount: '150000000' }
+ )
+ expect(plan).toMatchObject({ refusal: expect.stringMatching(/one withdrawal takes from one/) })
+ })
+
+ it('holds a named form to that form', () => {
+ const plan = planWithdraw(positions({ supplied: [balance('XLM', '5')] }), {
+ symbol: 'XLM',
+ from: 'collateral',
+ })
+ expect(plan).toMatchObject({ refusal: expect.stringMatching(/no XLM as collateral/) })
+ })
+})
+
+describe('repaying', () => {
+ it('finds the debt or says there is none', () => {
+ const debt = balance('USDC', '100')
+ expect(findDebt(positions({ borrowed: [debt] }), { symbol: 'usdc' })).toBe(debt)
+ expect(findDebt(positions({}), { symbol: 'USDC' })).toMatchObject({
+ refusal: expect.stringMatching(/owe no USDC.*Nothing was signed or sent\.$/),
+ })
+ })
+
+ it('asks for a little over the debt, because interest grows until it settles', () => {
+ expect(repayAllAmount(BigInt(1_000_000_000))).toBe(BigInt(1_000_100_100))
+ })
+
+ it('clears the debt when the wallet can cover the margin', () => {
+ expect(planRepayAll(BigInt(1_000_000_000), BigInt(2_000_000_000), 'USDC')).toEqual({
+ amount: '1000100100',
+ covers: true,
+ })
+ })
+
+ it('never asks for more than the wallet holds, because the pool takes it all up front', () => {
+ expect(planRepayAll(BigInt(1_000_000_000), BigInt(400_000_000), 'USDC')).toEqual({
+ amount: '400000000',
+ covers: false,
+ })
+ })
+
+ it('refuses when the wallet holds none of it', () => {
+ expect(planRepayAll(BigInt(100), BigInt(0), 'USDC')).toMatchObject({
+ refusal: expect.stringMatching(/no USDC free.*Nothing was signed or sent\.$/),
+ })
+ })
+
+ it('has no sentinel: a repayment with no amount is refused', async () => {
+ await expect(buildBlendRepay({ account: ME, asset: XLM, fetchImpl: horizon })).rejects.toThrow(
+ /needs an amount/
+ )
+ })
+
+ it('reads spendable XLM as the balance less the minimum, open orders and room for the fee', () => {
+ expect(
+ spendableXlm({
+ balances: [
+ { asset_type: 'native', balance: '10.0000000', selling_liabilities: '1.0000000' },
+ ],
+ subentry_count: 2,
+ })
+ ).toBe(BigInt(10 * 1e7) - BigInt(2 * 1e7) - BigInt(1e7) - BigInt(5_000_000))
+ expect(spendableXlm({ balances: [{ asset_type: 'native', balance: '1.0000000' }] })).toBe(
+ BigInt(0)
+ )
+ })
+})
+
+describe('what stays behind', () => {
+ it('values this reserve only, not whichever comes first in the map', () => {
+ const usdc = { ...balance('USDC', '200', 1), tokens: '100' }
+ expect(worthOf(usdc, { '0': '999999', '1': '50' })).toBe('100')
+ expect(worthOf(usdc, { '0': '999999' })).toBe('0')
+ expect(worthOf(undefined, { '1': '50' })).toBe('0')
+ })
+})
+
+describe('the envelope a withdrawal or repayment may carry', () => {
+ it('accepts what the builders make, right amount and paying the signer', async () => {
+ const whole = await buildBlendWithdraw({ account: ME, asset: XLM, fetchImpl: horizon })
+ expect(whole.everything).toBe(true)
+ expect(whole.amount).toBe(MAX)
+ expect(whole.recipient).toBe(ME)
+ expect(() => assertSelfWithdraw(whole.xdr, ME)).not.toThrow()
+
+ const both = await buildBlendWithdrawEverywhere({ account: ME, asset: XLM, fetchImpl: horizon })
+ expect(both.everything).toBe(true)
+ expect(() => assertSelfWithdraw(both.xdr, ME)).not.toThrow()
+
+ const repay = await buildBlendRepay({
+ account: ME,
+ asset: XLM,
+ amount: '1000',
+ fetchImpl: horizon,
+ })
+ expect(repay.amount).toBe('1000')
+ expect(() => assertSelfRepay(repay.xdr, ME)).not.toThrow()
+ })
+
+ it('refuses paying anyone else', () => {
+ expect(() => assertSelfWithdraw(poolCall({ to: STRANGER }), ME)).toThrow(/refusing to sign/)
+ expect(() => assertSelfRepay(poolCall({ to: STRANGER, requestType: 5 }), ME)).toThrow(
+ /refusing to sign/
+ )
+ })
+
+ it('refuses a contract that is not the pool', () => {
+ expect(() => assertSelfWithdraw(poolCall({ pool: OTHER_CONTRACT }), ME)).toThrow(
+ /is not Blend's pool/
+ )
+ })
+
+ it('refuses any other function', () => {
+ expect(() => assertSelfWithdraw(poolCall({ fn: 'gulp' }), ME)).toThrow(/Only submit/)
+ })
+
+ it('refuses a request of another kind, so a borrow cannot pass as a withdrawal', () => {
+ expect(() => assertSelfWithdraw(poolCall({ requestType: 4 }), ME)).toThrow(/something other/)
+ expect(() => assertSelfRepay(poolCall({ requestType: 4 }), ME)).toThrow(/something other/)
+ expect(() => assertSelfBorrow(poolCall({ requestType: 1 }), ME)).toThrow(/something other/)
+ expect(() => assertSelfCollateralWithdraw(poolCall({ requestType: 1 }), ME)).toThrow(
+ /something other/
+ )
+ })
+
+ it('accepts a collateral request as a withdrawal, since it pays the wallet too', () => {
+ expect(() => assertSelfWithdraw(poolCall({ requestType: 3 }), ME)).not.toThrow()
+ })
+})
+
+describe('naming reserves', () => {
+ it('finds a reserve by ticker, whatever the case', () => {
+ expect(blendReserveFor('xlm', 'mainnet')).toBe(OTHER_CONTRACT)
+ expect(blendReserveFor('USDC', 'mainnet')).toBe(
+ 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'
+ )
+ expect(blendReserveFor('DOGE', 'mainnet')).toBeUndefined()
+ })
+
+ it('shows an id it does not know rather than guessing a ticker', () => {
+ expect(blendSymbol('CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 'mainnet')).toBe(
+ 'USDC'
+ )
+ expect(blendSymbol('CAAAAAAAAAAAAAAAAAAAAAAAAAAZZZZ', 'mainnet')).toBe('CAAA…ZZZZ')
+ })
+
+ it('will not supply testnet USDC, which is not the token the pool lends', () => {
+ expect(suppliableReserve('XLM')).toBe(XLM)
+ expect(suppliableReserve('USDC')).toBeUndefined()
+ })
+})
+
+describe('refusals on the way out', () => {
+ it('tells someone taking back collateral to repay, not to post more', () => {
+ expect(explainExitError('HostError: Error(Contract, #1205)', 'withdraw')).toMatch(
+ /backing a loan.*Repay the loan first.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('says the wallet is short rather than showing the token error', () => {
+ expect(explainExitError('HostError: Error(Contract, #10)', 'repay')).toMatch(
+ /does not hold enough.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('reads "nothing to burn" on a debt it has just read as an amount too small', () => {
+ expect(explainExitError('HostError: Error(Contract, #1219)', 'repay')).toMatch(/too small/)
+ })
+
+ it('keeps an unknown code raw rather than guessing', () => {
+ const raw = 'HostError: Error(Contract, #9999)'
+ expect(explainExitError(raw, 'withdraw')).toBe(raw)
+ })
+})
+
+describe('the routes, when the position cannot be read', () => {
+ async function routes(): Promise<{
+ withdraw: (request: Request) => Promise
+ repay: (request: Request) => Promise
+ }> {
+ vi.resetModules()
+ vi.doMock('../lend/position', () => ({
+ readBlendPositions: vi.fn(async () => {
+ throw new Error('rpc down')
+ }),
+ }))
+ const withdraw = await import('../../app/api/lend/withdraw/route')
+ const repay = await import('../../app/api/lend/repay/route')
+ return { withdraw: withdraw.POST, repay: repay.POST }
+ }
+
+ const ask = (body: unknown): Request =>
+ new Request('http://localhost/api/lend/x', { method: 'POST', body: JSON.stringify(body) })
+
+ it('builds nothing, and does not say the position is empty', async () => {
+ const { withdraw, repay } = await routes()
+
+ const out = await withdraw(ask({ account: ME, symbol: 'XLM' }))
+ expect(out.status).toBe(502)
+ const body = (await out.json()) as { xdr?: string; error?: string }
+ expect(body.xdr).toBeUndefined()
+ expect(body.error).toMatch(/Could not read.*Nothing was signed or sent\./)
+
+ const back = await repay(ask({ account: ME, symbol: 'USDC' }))
+ expect(back.status).toBe(502)
+ expect(((await back.json()) as { xdr?: string }).xdr).toBeUndefined()
+ }, 30_000)
+
+ it('refuses an amount that is not a positive whole figure of base units', async () => {
+ const { withdraw } = await routes()
+ const out = await withdraw(ask({ account: ME, symbol: 'XLM', amount: '-5' }))
+ expect(out.status).toBe(400)
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/build-phoenix.live.test.ts b/apps/dapp/lib/__tests__/build-phoenix.live.test.ts
index f2675a95..667f5335 100644
--- a/apps/dapp/lib/__tests__/build-phoenix.live.test.ts
+++ b/apps/dapp/lib/__tests__/build-phoenix.live.test.ts
@@ -15,10 +15,16 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
* of which fails in simulation with an error that names none of those
* things, and none of which a fixture written from this module's own
* assumptions would ever see.
+ *
+ * The account is looked up rather than pinned. A simulation really moves the
+ * tokens, so it needs an account that can spend them, and any one wallet's
+ * balance is not ours to rely on: this suite once pinned a wallet that fell to
+ * 1.68 XLM, and every run then failed on the wallet with Phoenix working fine.
*/
const SKIP = process.env['SKIP_LIVE'] === '1'
-const ME = 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA'
+const HORIZON = 'https://horizon.stellar.org'
+const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
type Loaded = {
resolveAsset: typeof import('../swap/assets').resolveAsset
@@ -34,6 +40,32 @@ type Loaded = {
}
let mod: Loaded
+let funded: string
+
+interface HorizonAccount {
+ id: string
+ balances: { asset_type: string; asset_code?: string; balance: string; limit?: string }[]
+}
+
+/** An account with a USDC trustline and XLM to spare, so a swap tests Phoenix and not the wallet. */
+async function fundedAccount(): Promise {
+ const res = await fetch(`${HORIZON}/accounts?asset=USDC:${USDC_ISSUER}&limit=200&order=desc`)
+ if (!res.ok) throw new Error(`Horizon ${res.status}`)
+ const body = (await res.json()) as { _embedded: { records: HorizonAccount[] } }
+
+ const account = body._embedded.records.find((record) => {
+ const xlm = record.balances.find((b) => b.asset_type === 'native')
+ const usdc = record.balances.find((b) => b.asset_code === 'USDC')
+ return (
+ xlm !== undefined &&
+ Number(xlm.balance) > 100 &&
+ usdc !== undefined &&
+ Number(usdc.limit) - Number(usdc.balance) > 100
+ )
+ })
+ if (account === undefined) throw new Error('no funded account found on Horizon')
+ return account.id
+}
beforeAll(async () => {
vi.resetModules()
@@ -47,6 +79,7 @@ beforeAll(async () => {
])
const quoter = createPhoenixQuoter()
mod = { resolveAsset, buildPhoenixSwap, prepareSorobanSwap, quoter }
+ if (!SKIP) funded = await fundedAccount()
// Deliberately not unstubbed here: buildPhoenixSwap and assertVenueOn also
// read activeNetwork() at call time, inside the tests below, not only at
// construction. The stub stays live for the whole suite and is removed
@@ -78,7 +111,7 @@ describe.skipIf(SKIP)('a Phoenix swap the pool accepts', () => {
const minReceive = (BigInt(quote.quote.destAmount) / BigInt(2)).toString()
const built = await mod.buildPhoenixSwap({
- account: ME,
+ account: funded,
from: XLM,
to: USDC,
sendAmount: '10000000',
@@ -87,8 +120,8 @@ describe.skipIf(SKIP)('a Phoenix swap the pool accepts', () => {
})
const prepared = await mod.prepareSorobanSwap(built.xdr)
- expect(prepared.ok).toBe(true)
if (!prepared.ok) throw new Error(prepared.reason)
+ expect(prepared.ok).toBe(true)
}, 60_000)
it('is refused by the pool when the floor is above what it would pay', async () => {
@@ -106,7 +139,7 @@ describe.skipIf(SKIP)('a Phoenix swap the pool accepts', () => {
const tooHigh = (BigInt(quote.quote.destAmount) * BigInt(10)).toString()
const built = await mod.buildPhoenixSwap({
- account: ME,
+ account: funded,
from: XLM,
to: USDC,
sendAmount: '10000000',
@@ -116,5 +149,8 @@ describe.skipIf(SKIP)('a Phoenix swap the pool accepts', () => {
const prepared = await mod.prepareSorobanSwap(built.xdr)
expect(prepared.ok).toBe(false)
+ // Refused for the floor. A wallet that cannot pay fails too, and that would
+ // pass here while proving nothing about the floor.
+ if (!prepared.ok) expect(prepared.reason).not.toMatch(/resulting balance/)
}, 60_000)
})
diff --git a/apps/dapp/lib/__tests__/bulla.live.test.ts b/apps/dapp/lib/__tests__/bulla.live.test.ts
new file mode 100644
index 00000000..58e97cd5
--- /dev/null
+++ b/apps/dapp/lib/__tests__/bulla.live.test.ts
@@ -0,0 +1,79 @@
+import { http, type Address, type Hex } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ BULLA_CLAIM,
+ BULLA_INVOICE,
+ BULLA_OWNER,
+ BULLA_READ_ABI,
+ createBullaVenue,
+} from '../chains/evm/bulla'
+import { evmReader } from '../chains/evm/client'
+
+/**
+ * Read-only against Avalanche mainnet: nothing is signed or sent. These hold the card's claims to
+ * the chain, so a moved owner, a new fee or a lock shows up here before a person reads a stale note.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const RPC =
+ process.env['AVALANCHE_RPC_URL']?.trim() || 'https://avalanche-c-chain-rpc.publicnode.com'
+const reader = evmReader('avalanche', http(RPC, { timeout: 20_000, retryCount: 2 }))
+const BILLER = '0x1111111111111111111111111111111111111111'
+const CLIENT = '0x2222222222222222222222222222222222222222'
+
+describe.skipIf(SKIP)('Bulla on Avalanche', { timeout: 60_000 }, () => {
+ const read = (address: Address, functionName: never) =>
+ reader.readContract({ address, abi: BULLA_READ_ABI, functionName } as never) as Promise
+
+ it('has code at each pinned contract', async () => {
+ for (const address of [BULLA_CLAIM, BULLA_INVOICE]) {
+ const code = await reader.getCode({ address: address as Address })
+ expect((code ?? '0x').length).toBeGreaterThan(2)
+ }
+ })
+
+ it('wires BullaInvoice to this claim contract', async () => {
+ expect(await read(BULLA_INVOICE as Address, '_bullaClaim' as never)).toBe(BULLA_CLAIM)
+ })
+
+ it('is owned by the single key the card names', async () => {
+ expect(await read(BULLA_CLAIM as Address, 'owner' as never)).toBe(BULLA_OWNER)
+ expect(await read(BULLA_INVOICE as Address, 'owner' as never)).toBe(BULLA_OWNER)
+ expect(await reader.getCode({ address: BULLA_OWNER as Address })).toBeUndefined()
+ })
+
+ it('is unlocked, and charges no fee', async () => {
+ expect(await read(BULLA_CLAIM as Address, 'lockState' as never)).toBe(0)
+ expect(await read(BULLA_CLAIM as Address, 'CORE_PROTOCOL_FEE' as never)).toBe(BigInt(0))
+ expect(await read(BULLA_INVOICE as Address, 'protocolFeeBPS' as never)).toBe(0)
+ })
+
+ it('refuses to pay an invoice that is not there', async () => {
+ await expect(
+ createBullaVenue(reader).prepare(
+ { kind: 'pay_invoice', amount: '1', tokenIn: 'USDC', invoiceId: '999999999' },
+ BILLER
+ )
+ ).rejects.toThrow(/no invoice 999999999/)
+ })
+
+ it('accepts the bill this venue builds, and refuses it from anyone else', async () => {
+ const prepared = await createBullaVenue(reader).prepare(
+ { kind: 'invoice', amount: '500', tokenIn: 'USDC', recipient: CLIENT, dueDate: '2099-12-01' },
+ BILLER
+ )
+ if (prepared.signing.family !== 'evm') throw new Error('not evm')
+ const data = prepared.signing.data as Hex
+ await expect(
+ reader.call({ account: BILLER, to: BULLA_CLAIM as Address, data })
+ ).resolves.toBeDefined()
+ await expect(
+ reader.call({
+ account: '0x3333333333333333333333333333333333333333',
+ to: BULLA_CLAIM as Address,
+ data,
+ })
+ ).rejects.toThrow()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/bulla.test.ts b/apps/dapp/lib/__tests__/bulla.test.ts
new file mode 100644
index 00000000..1dc6bc4f
--- /dev/null
+++ b/apps/dapp/lib/__tests__/bulla.test.ts
@@ -0,0 +1,658 @@
+import {
+ decodeFunctionData,
+ encodeFunctionData,
+ erc20Abi,
+ maxUint256,
+ zeroAddress,
+ type Address,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ BULLA_CLAIM,
+ BULLA_CREATE_ABI,
+ BULLA_DESCRIPTION,
+ BULLA_INVOICE,
+ BULLA_PAY_ABI,
+ BULLA_INVOICE_PAY_ABI,
+ createBullaVenue,
+} from '../chains/evm/bulla'
+import { bullaListing } from '../chains/evm/bulla-listing'
+import type { EvmReaderClient } from '../chains/evm/call'
+import { AVALANCHE_TOKENS } from '../chains/evm/tokens'
+import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types'
+
+const ME = '0x1111111111111111111111111111111111111111'
+const CLIENT = '0x2222222222222222222222222222222222222222'
+const STRANGER = '0x3333333333333333333333333333333333333333'
+const USDC = AVALANCHE_TOKENS.find((t) => t.symbol === 'USDC')?.id as Address
+const USDT = AVALANCHE_TOKENS.find((t) => t.symbol === 'USDT')?.id as Address
+const AVALANCHE_ID = 43114
+const FIVE_HUNDRED = BigInt(500_000_000)
+
+interface ClaimFake {
+ claimAmount: bigint
+ paidAmount: bigint
+ dueBy: bigint
+ impairmentGracePeriod: bigint
+ originalCreditor: Address
+ debtor: Address
+ creditor: Address
+ token: Address
+ controller: Address
+ status: number
+ binding: number
+}
+
+const claimOf = (over: Partial = {}): ClaimFake => ({
+ claimAmount: FIVE_HUNDRED,
+ paidAmount: BigInt(0),
+ dueBy: BigInt(4_000_000_000),
+ impairmentGracePeriod: BigInt(0),
+ originalCreditor: CLIENT,
+ debtor: ME,
+ creditor: CLIENT,
+ token: USDC,
+ controller: zeroAddress,
+ status: 0,
+ binding: 0,
+ ...over,
+})
+
+interface InvoiceFake {
+ deliveryDate?: bigint
+ interestRateBps?: number
+ accruedInterest?: bigint
+ protocolFeeBps?: number
+}
+
+interface FakeChain {
+ lockState?: number
+ fee?: bigint
+ count?: bigint
+ claim?: ClaimFake
+ invoice?: InvoiceFake
+ callback?: Address
+ allowance?: bigint
+ callFails?: boolean
+ readFails?: boolean
+}
+
+function readerFor(chain: FakeChain = {}) {
+ const calls: { to: string; data: string }[] = []
+ const claim = chain.claim ?? claimOf()
+ const reader = {
+ readContract: async ({ functionName, address }: { functionName: string; address: string }) => {
+ if (chain.readFails === true) throw new Error('rpc down')
+ switch (functionName) {
+ case 'lockState':
+ return chain.lockState ?? 0
+ case 'CORE_PROTOCOL_FEE':
+ return chain.fee ?? BigInt(0)
+ case 'currentClaimId':
+ return chain.count ?? BigInt(100)
+ case 'getClaim':
+ return claim
+ case 'getPaidClaimCallback':
+ return {
+ callbackContract: chain.callback ?? zeroAddress,
+ callbackSelector: chain.callback === undefined ? '0x00000000' : '0x12345678',
+ }
+ case 'getInvoice':
+ return {
+ ...claim,
+ purchaseOrder: {
+ deliveryDate: chain.invoice?.deliveryDate ?? BigInt(0),
+ depositAmount: BigInt(0),
+ isDelivered: false,
+ },
+ lateFeeConfig: {
+ interestRateBps: chain.invoice?.interestRateBps ?? 0,
+ numberOfPeriodsPerYear: 0,
+ },
+ interestComputationState: {
+ accruedInterest: chain.invoice?.accruedInterest ?? BigInt(0),
+ latestPeriodNumber: BigInt(0),
+ protocolFeeBps: chain.invoice?.protocolFeeBps ?? 0,
+ totalGrossInterestPaid: BigInt(0),
+ },
+ }
+ case 'allowance':
+ return chain.allowance ?? BigInt(0)
+ default:
+ throw new Error(`unexpected read ${functionName} on ${address}`)
+ }
+ },
+ call: async (args: { to: string; data: string }) => {
+ calls.push({ to: args.to, data: args.data })
+ if (chain.callFails === true) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ } as unknown as EvmReaderClient
+ return { reader, calls }
+}
+
+const bill = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'invoice',
+ amount: '500',
+ tokenIn: 'USDC',
+ recipient: CLIENT,
+ dueDate: '2099-12-01',
+ ...over,
+})
+
+const pay = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'pay_invoice',
+ amount: '500',
+ tokenIn: 'USDC',
+ invoiceId: '12',
+ ...over,
+})
+
+function evmPayload(signing: SigningPayload) {
+ if (signing.family !== 'evm') throw new Error('not evm')
+ return signing
+}
+
+const refusal = (reason: RegExp) =>
+ expect.objectContaining({
+ constructor: ChainActionRefused,
+ message: expect.stringMatching(reason),
+ })
+
+function billingCalldata(over: Record = {}): string {
+ return encodeFunctionData({
+ abi: BULLA_CREATE_ABI,
+ functionName: 'createClaim',
+ args: [
+ {
+ creditor: ME,
+ debtor: CLIENT,
+ claimAmount: FIVE_HUNDRED,
+ description: BULLA_DESCRIPTION,
+ token: USDC,
+ binding: 0,
+ dueBy: BigInt(4_000_000_000),
+ impairmentGracePeriod: BigInt(0),
+ ...over,
+ },
+ ],
+ } as never)
+}
+
+const payCalldata = (id = BigInt(12), units = FIVE_HUNDRED) =>
+ encodeFunctionData({ abi: BULLA_PAY_ABI, functionName: 'payClaim', args: [id, units] })
+
+const approveData = (spender: string, units: bigint) =>
+ encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender as Address, units] })
+
+const payload = (
+ over: Partial> = {}
+): SigningPayload => ({
+ family: 'evm',
+ chainId: AVALANCHE_ID,
+ to: BULLA_CLAIM,
+ data: billingCalldata(),
+ value: '0',
+ ...over,
+})
+
+describe('billing with Bulla', () => {
+ it('bills the typed address, in the person’s name, with nothing but gas', async () => {
+ const { reader } = readerFor()
+ const prepared = await createBullaVenue(reader).prepare(bill(), ME)
+ const signing = evmPayload(prepared.signing)
+ expect(signing.to).toBe(BULLA_CLAIM)
+ expect(signing.value).toBe('0')
+ expect(signing.approval).toBeUndefined()
+ const decoded = decodeFunctionData({
+ abi: BULLA_CREATE_ABI,
+ data: signing.data as `0x${string}`,
+ })
+ expect(decoded.functionName).toBe('createClaim')
+ expect(decoded.args[0]).toMatchObject({
+ creditor: ME,
+ debtor: CLIENT,
+ claimAmount: FIVE_HUNDRED,
+ token: USDC,
+ binding: 0,
+ description: BULLA_DESCRIPTION,
+ impairmentGracePeriod: BigInt(0),
+ })
+ expect(decoded.args[0].dueBy).toBe(BigInt(Date.UTC(2099, 11, 1, 23, 59, 59) / 1000))
+ expect(prepared.kind).toBe('invoice')
+ expect(prepared.pay).toEqual({ amount: '500', symbol: 'USDC' })
+ })
+
+ it('names the auditor and the owner’s powers on the card', async () => {
+ const { reader } = readerFor()
+ const { notes } = await createBullaVenue(reader).prepare(bill(), ME)
+ const text = notes.join(' ')
+ expect(text).toMatch(/LPHAC/)
+ expect(text).toMatch(/v2\.2/)
+ expect(text).toMatch(/v2\.4/)
+ expect(text).toMatch(/lock/i)
+ expect(text).toMatch(/fee/i)
+ expect(text).toMatch(/reject/i)
+ })
+
+ it('tries the call before offering it', async () => {
+ const { reader, calls } = readerFor()
+ await createBullaVenue(reader).prepare(bill(), ME)
+ expect(calls).toHaveLength(1)
+ await expect(
+ createBullaVenue(readerFor({ callFails: true }).reader).prepare(bill(), ME)
+ ).rejects.toThrow(/fail on the chain/)
+ })
+
+ it('refuses a due date in the past', async () => {
+ const { reader } = readerFor()
+ await expect(
+ createBullaVenue(reader).prepare(bill({ dueDate: '2020-01-01' }), ME)
+ ).rejects.toThrow(/already passed.*Nothing was signed or sent\./)
+ })
+
+ it('takes a due date of today', async () => {
+ const today = new Date().toISOString().slice(0, 10)
+ const prepared = await createBullaVenue(readerFor().reader).prepare(
+ bill({ dueDate: today }),
+ ME
+ )
+ expect(prepared.kind).toBe('invoice')
+ })
+
+ it.each(['2099-02-30', '2099-13-01', 'next week', '20991201', undefined])(
+ 'refuses %s as a due date',
+ async (dueDate) => {
+ await expect(
+ createBullaVenue(readerFor().reader).prepare(bill({ dueDate }), ME)
+ ).rejects.toThrow(/due date/)
+ }
+ )
+
+ it('refuses a fee instead of hiding one, and names it', async () => {
+ const fee = BigInt('20000000000000000')
+ await expect(createBullaVenue(readerFor({ fee }).reader).prepare(bill(), ME)).rejects.toThrow(
+ /0\.02 AVAX.*Nothing was signed or sent\./
+ )
+ })
+
+ it.each([1, 2])('refuses when Bulla is locked (state %i)', async (lockState) => {
+ await expect(
+ createBullaVenue(readerFor({ lockState }).reader).prepare(bill(), ME)
+ ).rejects.toThrow(/locked/)
+ })
+
+ it('refuses to bill in AVAX, which this venue does not handle', async () => {
+ await expect(
+ createBullaVenue(readerFor().reader).prepare(bill({ tokenIn: 'AVAX' }), ME)
+ ).rejects.toThrow(/USDC or USDT/)
+ })
+
+ it('refuses a listed token that isn’t a stablecoin it bills in', async () => {
+ await expect(
+ createBullaVenue(readerFor().reader).prepare(bill({ tokenIn: 'WAVAX' }), ME)
+ ).rejects.toThrow(/USDC or USDT/)
+ })
+
+ it('refuses a token not on the chain’s list', async () => {
+ await expect(
+ createBullaVenue(readerFor().reader).prepare(bill({ tokenIn: 'SCAM' }), ME)
+ ).rejects.toThrow(/USDC or USDT/)
+ })
+
+ it('refuses a shortened, zero or missing address, and the person’s own', async () => {
+ const venue = createBullaVenue(readerFor().reader)
+ await expect(venue.prepare(bill({ recipient: '0xabc' }), ME)).rejects.toThrow(
+ ChainActionRefused
+ )
+ await expect(venue.prepare(bill({ recipient: zeroAddress }), ME)).rejects.toThrow(
+ ChainActionRefused
+ )
+ await expect(venue.prepare(bill({ recipient: undefined }), ME)).rejects.toThrow(
+ ChainActionRefused
+ )
+ await expect(venue.prepare(bill({ recipient: ME }), ME)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses when the chain can’t be read', async () => {
+ await expect(
+ createBullaVenue(readerFor({ readFails: true }).reader).prepare(bill(), ME)
+ ).rejects.toThrow(/couldn't read/)
+ })
+
+ it('refuses an account that is not an address', async () => {
+ await expect(
+ createBullaVenue(readerFor().reader).prepare(bill(), 'alice.near')
+ ).rejects.toThrow(/not an Avalanche address/)
+ })
+
+ it('refuses a kind it does not do', async () => {
+ await expect(
+ createBullaVenue(readerFor().reader).prepare(bill({ kind: 'send' }), ME)
+ ).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('paying with Bulla', () => {
+ it('pays a plain claim through the claim contract, with an exact approval when needed', async () => {
+ const { reader, calls } = readerFor()
+ const prepared = await createBullaVenue(reader).prepare(pay(), ME)
+ const signing = evmPayload(prepared.signing)
+ expect(signing.to).toBe(BULLA_CLAIM)
+ expect(signing.value).toBe('0')
+ expect(signing.data).toBe(payCalldata())
+ expect(signing.approval).toEqual({ to: USDC, data: approveData(BULLA_CLAIM, FIVE_HUNDRED) })
+ expect(calls).toHaveLength(0)
+ expect(prepared.notes.join(' ')).toMatch(/asks twice/)
+ expect(prepared.summary).toContain(CLIENT)
+ })
+
+ it('skips the approval and tries the call when the allowance covers it', async () => {
+ const { reader, calls } = readerFor({ allowance: FIVE_HUNDRED })
+ const signing = evmPayload((await createBullaVenue(reader).prepare(pay(), ME)).signing)
+ expect(signing.approval).toBeUndefined()
+ expect(calls).toHaveLength(1)
+ })
+
+ it('pays an invoice made through BullaInvoice on that contract', async () => {
+ const claim = claimOf({ controller: BULLA_INVOICE })
+ const { reader } = readerFor({ claim })
+ const signing = evmPayload((await createBullaVenue(reader).prepare(pay(), ME)).signing)
+ expect(signing.to).toBe(BULLA_INVOICE)
+ expect(signing.data).toBe(
+ encodeFunctionData({
+ abi: BULLA_INVOICE_PAY_ABI,
+ functionName: 'payInvoice',
+ args: [BigInt(12), FIVE_HUNDRED],
+ })
+ )
+ expect(signing.approval).toEqual({ to: USDC, data: approveData(BULLA_INVOICE, FIVE_HUNDRED) })
+ })
+
+ it('counts late-fee interest as owed, and shows the fee Bulla keeps of it', async () => {
+ const claim = claimOf({ controller: BULLA_INVOICE })
+ const invoice = {
+ interestRateBps: 1000,
+ accruedInterest: BigInt(10_000_000),
+ protocolFeeBps: 500,
+ }
+ const venue = createBullaVenue(readerFor({ claim, invoice }).reader)
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/510 USDC/)
+ const prepared = await venue.prepare(pay({ amount: '510' }), ME)
+ const text = prepared.notes.join(' ')
+ expect(text).toMatch(/10 USDC.*late/i)
+ expect(text).toMatch(/5%/)
+ })
+
+ it('refuses a purchase order', async () => {
+ const claim = claimOf({ controller: BULLA_INVOICE })
+ await expect(
+ createBullaVenue(
+ readerFor({ claim, invoice: { deliveryDate: BigInt(4_000_000_000) } }).reader
+ ).prepare(pay(), ME)
+ ).rejects.toThrow(/purchase order/)
+ })
+
+ it('refuses a claim run by any other contract', async () => {
+ const claim = claimOf({ controller: STRANGER as Address })
+ await expect(createBullaVenue(readerFor({ claim }).reader).prepare(pay(), ME)).rejects.toThrow(
+ /isn't an invoice/
+ )
+ })
+
+ it('refuses an amount that is not exactly what is owed', async () => {
+ const venue = createBullaVenue(readerFor().reader)
+ await expect(venue.prepare(pay({ amount: '499.99' }), ME)).rejects.toThrow(
+ /500 USDC, not 499\.99/
+ )
+ await expect(venue.prepare(pay({ amount: '500.01' }), ME)).rejects.toThrow(
+ /500 USDC, not 500\.01/
+ )
+ })
+
+ it('counts what has already been paid', async () => {
+ const claim = claimOf({ paidAmount: BigInt(200_000_000), status: 1 })
+ const venue = createBullaVenue(readerFor({ claim }).reader)
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/300 USDC/)
+ const signing = evmPayload((await venue.prepare(pay({ amount: '300' }), ME)).signing)
+ expect(signing.data).toBe(payCalldata(BigInt(12), BigInt(300_000_000)))
+ })
+
+ it('refuses the wrong token', async () => {
+ const venue = createBullaVenue(readerFor({ claim: claimOf({ token: USDT }) }).reader)
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/USDT, not USDC/)
+ })
+
+ it('refuses a claim in a token this app does not list', async () => {
+ const venue = createBullaVenue(
+ readerFor({ claim: claimOf({ token: STRANGER as Address }) }).reader
+ )
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/different token/)
+ })
+
+ it.each([
+ [2, /already been paid/],
+ [3, /rejected/],
+ [4, /cancelled/],
+ [5, /impaired/],
+ [9, /can't be paid/],
+ ])('refuses status %i', async (status, reason) => {
+ const venue = createBullaVenue(readerFor({ claim: claimOf({ status }) }).reader)
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(reason)
+ })
+
+ it('refuses an invoice billed to someone else', async () => {
+ const venue = createBullaVenue(
+ readerFor({ claim: claimOf({ debtor: STRANGER as Address }) }).reader
+ )
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/billed to .*not this wallet/)
+ })
+
+ it('refuses an invoice that does not exist', async () => {
+ const venue = createBullaVenue(readerFor({ count: BigInt(12) }).reader)
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/no invoice 12/)
+ })
+
+ it.each(['12a', '-1', '', undefined])('refuses %s as an invoice id', async (invoiceId) => {
+ const venue = createBullaVenue(readerFor().reader)
+ await expect(venue.prepare(pay({ invoiceId }), ME)).rejects.toThrow(/invoice number/)
+ })
+
+ it('refuses a claim with a paid callback, which runs code nobody here has reviewed', async () => {
+ const venue = createBullaVenue(readerFor({ callback: STRANGER as Address }).reader)
+ await expect(venue.prepare(pay(), ME)).rejects.toThrow(/runs another contract/)
+ })
+
+ it('refuses when fully locked, but pays when only new claims are stopped', async () => {
+ await expect(
+ createBullaVenue(readerFor({ lockState: 2 }).reader).prepare(pay(), ME)
+ ).rejects.toThrow(/locked/)
+ const prepared = await createBullaVenue(readerFor({ lockState: 1 }).reader).prepare(pay(), ME)
+ expect(prepared.kind).toBe('pay_invoice')
+ })
+
+ it('states who gets paid, and the audit gap', async () => {
+ const { notes } = await createBullaVenue(readerFor().reader).prepare(pay(), ME)
+ const text = notes.join(' ')
+ expect(text).toContain(CLIENT)
+ expect(text).toMatch(/LPHAC/)
+ expect(text).toMatch(/v2\.4/)
+ })
+})
+
+describe('assertSafe', () => {
+ const venue = createBullaVenue(readerFor().reader)
+ const check = (signing: SigningPayload, account = ME) => venue.assertSafe(signing, account)
+
+ it('passes what the venue builds', async () => {
+ const built = await venue.prepare(bill(), ME)
+ expect(() => venue.assertSafe(built.signing, ME)).not.toThrow()
+ const paid = await venue.prepare(pay(), ME)
+ expect(() => venue.assertSafe(paid.signing, ME)).not.toThrow()
+ const viaInvoice = await createBullaVenue(
+ readerFor({ claim: claimOf({ controller: BULLA_INVOICE }) }).reader
+ ).prepare(pay(), ME)
+ expect(() => venue.assertSafe(viaInvoice.signing, ME)).not.toThrow()
+ })
+
+ it('refuses the wrong account', () => {
+ expect(() => check(payload(), STRANGER)).toThrow(/you as the one owed/)
+ })
+
+ it('refuses credit going to anyone else', () => {
+ expect(() => check(payload({ data: billingCalldata({ creditor: STRANGER }) }))).toThrow(
+ /you as the one owed/
+ )
+ })
+
+ it('refuses billing yourself or nobody', () => {
+ expect(() => check(payload({ data: billingCalldata({ debtor: ME }) }))).toThrow(/bills/)
+ expect(() => check(payload({ data: billingCalldata({ debtor: zeroAddress }) }))).toThrow(
+ /bills/
+ )
+ })
+
+ it('refuses an unknown contract', () => {
+ expect(() => check(payload({ to: STRANGER }))).toThrow(/haven't confirmed for Bulla/)
+ expect(() => check(payload({ to: USDC }))).toThrow(/haven't confirmed for Bulla/)
+ })
+
+ it('refuses the wrong chain and another family', () => {
+ expect(() => check(payload({ chainId: 143 }))).toThrow(/not for Avalanche/)
+ expect(() => check({ family: 'solana', transaction: 'AA==' })).toThrow(
+ /not a Avalanche|not an Avalanche/
+ )
+ })
+
+ it('refuses an unknown function', () => {
+ const data = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [STRANGER as Address, FIVE_HUNDRED],
+ })
+ expect(() => check(payload({ data }))).toThrow(/haven't confirmed for Bulla/)
+ expect(() => check(payload({ to: BULLA_INVOICE, data: billingCalldata() }))).toThrow(
+ /haven't confirmed for Bulla/
+ )
+ })
+
+ it('refuses extra calldata', () => {
+ expect(() => check(payload({ data: `${billingCalldata()}00` }))).toThrow(/extra data/)
+ expect(() => check(payload({ data: `${payCalldata()}deadbeef` }))).toThrow(/extra data/)
+ })
+
+ it('refuses any value on either call', () => {
+ expect(() => check(payload({ value: '1' }))).toThrow(/spends AVAX/)
+ expect(() => check(payload({ data: payCalldata(), value: '1' }))).toThrow(/spends AVAX/)
+ })
+
+ it('refuses a token it does not bill in', () => {
+ expect(() => check(payload({ data: billingCalldata({ token: STRANGER }) }))).toThrow(/token/)
+ expect(() => check(payload({ data: billingCalldata({ token: zeroAddress }) }))).toThrow(/token/)
+ })
+
+ it('refuses a bound claim, a late grace period, stray description or no due date', () => {
+ expect(() => check(payload({ data: billingCalldata({ binding: 1 }) }))).toThrow(/terms/)
+ expect(() =>
+ check(payload({ data: billingCalldata({ impairmentGracePeriod: BigInt(1) }) }))
+ ).toThrow(/terms/)
+ expect(() =>
+ check(payload({ data: billingCalldata({ description: 'pay here: evil.example' }) }))
+ ).toThrow(/terms/)
+ expect(() => check(payload({ data: billingCalldata({ dueBy: BigInt(0) }) }))).toThrow(/terms/)
+ expect(() => check(payload({ data: billingCalldata({ claimAmount: BigInt(0) }) }))).toThrow(
+ /terms/
+ )
+ expect(() => check(payload({ data: billingCalldata({ claimAmount: maxUint256 }) }))).toThrow(
+ /terms/
+ )
+ })
+
+ it('refuses any approval alongside a bill, which moves nothing', () => {
+ expect(() =>
+ check(payload({ approval: { to: USDC, data: approveData(BULLA_CLAIM, FIVE_HUNDRED) } }))
+ ).toThrow(/moves nothing/)
+ })
+
+ it('refuses an unlimited approval', () => {
+ expect(() =>
+ check(
+ payload({
+ data: payCalldata(),
+ approval: { to: USDC, data: approveData(BULLA_CLAIM, maxUint256) },
+ })
+ )
+ ).toThrow(/unlimited/)
+ })
+
+ it('refuses an approval for the wrong amount', () => {
+ expect(() =>
+ check(
+ payload({
+ data: payCalldata(),
+ approval: { to: USDC, data: approveData(BULLA_CLAIM, FIVE_HUNDRED + BigInt(1)) },
+ })
+ )
+ ).toThrow(/exactly/)
+ })
+
+ it('refuses an approval to someone else, or to the other Bulla contract', () => {
+ expect(() =>
+ check(
+ payload({
+ data: payCalldata(),
+ approval: { to: USDC, data: approveData(STRANGER, FIVE_HUNDRED) },
+ })
+ )
+ ).toThrow(/isn't confirmed for Bulla/)
+ expect(() =>
+ check(
+ payload({
+ data: payCalldata(),
+ approval: { to: USDC, data: approveData(BULLA_INVOICE, FIVE_HUNDRED) },
+ })
+ )
+ ).toThrow(/exactly/)
+ })
+
+ it('refuses approving a token it does not take', () => {
+ expect(() =>
+ check(
+ payload({
+ data: payCalldata(),
+ approval: { to: STRANGER, data: approveData(BULLA_CLAIM, FIVE_HUNDRED) },
+ })
+ )
+ ).toThrow(/approve a token/)
+ })
+
+ it('refuses a payment of nothing', () => {
+ expect(() => check(payload({ data: payCalldata(BigInt(12), BigInt(0)) }))).toThrow(/nothing/)
+ })
+})
+
+describe('venue shape', () => {
+ it('does only the two invoice kinds, and is never raced', () => {
+ const venue = createBullaVenue(readerFor().reader)
+ expect(venue.id).toBe('bulla')
+ expect(venue.kinds).toEqual(['invoice', 'pay_invoice'])
+ expect(venue.quote).toBeUndefined()
+ expect(venue.submit).toBeTypeOf('function')
+ })
+
+ it('lists as executing on Avalanche', () => {
+ expect(bullaListing).toMatchObject({
+ id: 'bulla',
+ family: 'evm',
+ chains: ['Avalanche'],
+ integration: 'executes',
+ })
+ })
+})
+
+it('is a refusal type, not a bare error', async () => {
+ await expect(
+ createBullaVenue(readerFor({ lockState: 2 }).reader).prepare(pay(), ME)
+ ).rejects.toEqual(refusal(/locked/))
+})
diff --git a/apps/dapp/lib/__tests__/chain-action-kinds.test.ts b/apps/dapp/lib/__tests__/chain-action-kinds.test.ts
new file mode 100644
index 00000000..64e36548
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-action-kinds.test.ts
@@ -0,0 +1,216 @@
+import { describe, expect, it } from 'vitest'
+
+import { chainHelp } from '../chains/explain'
+import { looksLikeChainAction, parseChainAction, racesFirst } from '../chains/parse-action'
+import { assertValidAmount, prepareChainAction } from '../chains/prepare'
+import {
+ ChainActionRefused,
+ type ChainActionKind,
+ type ChainModule,
+ type ChainToken,
+ type ChainVenue,
+ type PreparedAction,
+} from '../chains/types'
+
+const ADDRESS = '0xAbC0000000000000000000000000000000000001'
+const TOKEN: ChainToken = { symbol: 'USDC', id: '0xusdc', decimals: 6 }
+const OTHER: ChainToken = { symbol: 'WAL', id: '0xwal', decimals: 9 }
+
+function venueNamed(id: string, kinds: readonly ChainActionKind[]): ChainVenue {
+ return {
+ id,
+ name: id,
+ kinds,
+ prepare: async (request): Promise => ({
+ venueId: id,
+ venueName: id,
+ kind: request.kind,
+ summary: id,
+ pay: { amount: request.amount, symbol: request.tokenIn },
+ notes: [],
+ signing: { family: 'sui', transaction: 'AA==' },
+ }),
+ assertSafe: () => undefined,
+ }
+}
+
+function moduleWith(overrides: Partial = {}): ChainModule {
+ return {
+ chain: 'sui',
+ tokens: [TOKEN, OTHER],
+ venues: [],
+ defaultVenue: {},
+ priceUsd: async () => undefined,
+ submit: async () => ({ ok: false, error: 'unused' }),
+ balances: async () => ({ tokens: [], complete: true }),
+ ...overrides,
+ }
+}
+
+describe('reading position actions', () => {
+ it.each([
+ [
+ 'withdraw 25 USDC from Aave',
+ { kind: 'withdraw', amount: '25', tokenIn: 'USDC', venue: 'Aave' },
+ ],
+ ['redeem 1,000 USDC', { kind: 'withdraw', amount: '1000', tokenIn: 'USDC' }],
+ [
+ 'unstake 1 sSUI from SpringSui',
+ { kind: 'unstake', amount: '1', tokenIn: 'sSUI', venue: 'SpringSui' },
+ ],
+ ['borrow 50 USDT0 on Aave', { kind: 'borrow', amount: '50', tokenIn: 'USDT0', venue: 'Aave' }],
+ ['repay 50 USDT0 on Aave', { kind: 'repay', amount: '50', tokenIn: 'USDT0', venue: 'Aave' }],
+ ['please pay back 5 USDC', { kind: 'repay', amount: '5', tokenIn: 'USDC' }],
+ ])('reads %s', (text, expected) => {
+ expect(parseChainAction(text)).toMatchObject(expected)
+ })
+
+ it('reads a chain’s name as its token', () => {
+ expect(parseChainAction('unstake 2 monad')).toMatchObject({ kind: 'unstake', tokenIn: 'MON' })
+ })
+})
+
+describe('reading payment actions', () => {
+ it('reads a stream in days, with the address as typed', () => {
+ expect(parseChainAction(`stream 500 USDC to ${ADDRESS} over 30 days`)).toEqual({
+ kind: 'stream',
+ amount: '500',
+ tokenIn: 'USDC',
+ recipient: ADDRESS,
+ durationDays: '30',
+ venue: undefined,
+ })
+ })
+
+ it('reads a month as 30 days', () => {
+ expect(parseChainAction(`pay 500 USDC to ${ADDRESS} over 2 months`)).toMatchObject({
+ kind: 'stream',
+ durationDays: '60',
+ })
+ })
+
+ it('reads a stream to a name and through a named venue', () => {
+ expect(parseChainAction('stream 5 USDC to ada.sol over 7 days with Streamflow')).toMatchObject({
+ recipient: 'ada.sol',
+ venue: 'Streamflow',
+ })
+ })
+
+ it.each([
+ `stream 500 USDC to ${ADDRESS}`,
+ `stream 500 USDC to ${ADDRESS} over 0 days`,
+ `stream 500 USDC to ${ADDRESS} over soon`,
+ `pay 500 USDC to ${ADDRESS}`,
+ ])('reads no stream from %s, since nothing is left to a default', (text) => {
+ expect(parseChainAction(text)).toBeNull()
+ })
+
+ it('reads an invoice', () => {
+ expect(parseChainAction(`invoice ${ADDRESS} 500 USDC due 2026-12-01`)).toMatchObject({
+ kind: 'invoice',
+ recipient: ADDRESS,
+ amount: '500',
+ tokenIn: 'USDC',
+ dueDate: '2026-12-01',
+ })
+ })
+
+ it('needs a due date on an invoice', () => {
+ expect(parseChainAction(`invoice ${ADDRESS} 500 USDC`)).toBeNull()
+ })
+
+ it('reads paying an invoice, and needs its amount said', () => {
+ expect(parseChainAction('pay invoice #12 with 500 USDC')).toMatchObject({
+ kind: 'pay_invoice',
+ invoiceId: '12',
+ amount: '500',
+ tokenIn: 'USDC',
+ })
+ expect(parseChainAction('pay invoice 12')).toBeNull()
+ })
+
+ it('still reads a plain send as a send', () => {
+ expect(parseChainAction(`send 5 USDC to ${ADDRESS}`)).toMatchObject({ kind: 'send' })
+ })
+})
+
+describe('choosing a venue when none is named', () => {
+ const chainApi = moduleWith({
+ venues: [venueNamed('general', ['stake']), venueNamed('only-wal', ['stake'])],
+ defaultVenue: { stake: 'general' },
+ venueForToken: { stake: { WAL: 'only-wal' } },
+ priceUsd: async () => 1,
+ })
+
+ it('sends a token only one venue takes to that venue, in any case', async () => {
+ const built = await prepareChainAction(
+ chainApi,
+ { kind: 'stake', amount: '1', tokenIn: 'wal' },
+ ADDRESS
+ )
+ expect(built.venueId).toBe('only-wal')
+ })
+
+ it('leaves every other token on the default', async () => {
+ const built = await prepareChainAction(
+ chainApi,
+ { kind: 'stake', amount: '1', tokenIn: 'USDC' },
+ ADDRESS
+ )
+ expect(built.venueId).toBe('general')
+ })
+
+ it('still lets the person name another venue', async () => {
+ const built = await prepareChainAction(
+ chainApi,
+ { kind: 'stake', amount: '1', tokenIn: 'WAL', venue: 'general' },
+ ADDRESS
+ )
+ expect(built.venueId).toBe('general')
+ })
+})
+
+describe('the amount', () => {
+ it.each(['1', '1000000', '0.000001'])('takes %s, whatever it is worth', (amount) => {
+ expect(() => assertValidAmount({ amount })).not.toThrow()
+ })
+
+ it.each(['0', '-1', 'lots', ''])('refuses %j, which is not an amount', (amount) => {
+ expect(() => assertValidAmount({ amount })).toThrow(ChainActionRefused)
+ })
+})
+
+describe('what these kinds do around the chat', () => {
+ it('races only a choice between venues', () => {
+ expect(racesFirst({ kind: 'swap' })).toBe(true)
+ expect(racesFirst({ kind: 'supply' })).toBe(true)
+ expect(racesFirst({ kind: 'stake' })).toBe(true)
+ for (const kind of [
+ 'send',
+ 'withdraw',
+ 'unstake',
+ 'borrow',
+ 'repay',
+ 'stream',
+ 'invoice',
+ 'pay_invoice',
+ ] as const) {
+ expect(racesFirst({ kind })).toBe(false)
+ }
+ })
+
+ it('tells someone what works when it can’t read the verbs', () => {
+ for (const verb of ['withdraw', 'unstake', 'repay', 'stream', 'invoice', 'redeem']) {
+ expect(looksLikeChainAction(`${verb} something`)).toBe(true)
+ }
+ })
+
+ it('offers each kind as words someone can copy', () => {
+ const help = chainHelp('Monad', [
+ { kind: 'withdraw', venue: 'Aave', symbol: 'USDC' },
+ { kind: 'stream', venue: 'Sablier', symbol: 'USDC' },
+ ])
+ expect(help).toContain('withdraw 25 USDC from Aave')
+ expect(help).toContain('stream 100 USDC to an address over 30 days')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts
index eb2efba1..d9d11605 100644
--- a/apps/dapp/lib/__tests__/chain-action.test.ts
+++ b/apps/dapp/lib/__tests__/chain-action.test.ts
@@ -169,30 +169,30 @@ describe('prepareChainAction', () => {
const swap = { kind: 'swap', amount: '10', tokenIn: 'usdc', tokenOut: 'SOL' } as const
it('prepares a valid action', async () => {
- await expect(prepareChainAction(moduleWith(), swap, 'acct', {})).resolves.toBe(prepared)
+ await expect(prepareChainAction(moduleWith(), swap, 'acct')).resolves.toBe(prepared)
})
it('refuses a token it does not know', async () => {
await expect(
- prepareChainAction(moduleWith(), { ...swap, tokenOut: 'SCAM' }, 'acct', {})
+ prepareChainAction(moduleWith(), { ...swap, tokenOut: 'SCAM' }, 'acct')
).rejects.toThrow(/verified token/)
})
- it('refuses a trade it cannot price, even when the Stellar default is testnet', async () => {
+ it('builds a trade it cannot price, since nothing here depends on a dollar value', async () => {
await expect(
- prepareChainAction(moduleWith({ priceUsd: async () => undefined }), swap, 'acct', {})
- ).rejects.toThrow(/could not be valued/)
+ prepareChainAction(moduleWith({ priceUsd: async () => undefined }), swap, 'acct')
+ ).resolves.toBeDefined()
})
- it('refuses a trade over the cap', async () => {
+ it('takes a trade of any size', async () => {
await expect(
- prepareChainAction(moduleWith(), { ...swap, amount: '501' }, 'acct', {})
- ).rejects.toThrow(/caps each trade/)
+ prepareChainAction(moduleWith(), { ...swap, amount: '5000000' }, 'acct')
+ ).resolves.toBeDefined()
})
it('refuses a venue that cannot do this', async () => {
await expect(
- prepareChainAction(moduleWith(), { ...swap, venue: 'nope' }, 'acct', {})
+ prepareChainAction(moduleWith(), { ...swap, venue: 'nope' }, 'acct')
).rejects.toThrow(/can't swap here/)
})
@@ -200,7 +200,7 @@ describe('prepareChainAction', () => {
const unsafe = (): void => {
throw new ChainActionRefused('unexpected program')
}
- await expect(prepareChainAction(moduleWith({}, unsafe), swap, 'acct', {})).rejects.toThrow(
+ await expect(prepareChainAction(moduleWith({}, unsafe), swap, 'acct')).rejects.toThrow(
/unexpected program/
)
})
diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts
index 7cd32bbd..46085f87 100644
--- a/apps/dapp/lib/__tests__/chain-balances.test.ts
+++ b/apps/dapp/lib/__tests__/chain-balances.test.ts
@@ -19,7 +19,7 @@ const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
const ODD_MINT = 'AbCdEfGhJkLmNpQrStUvWxYzAbCdEfGhJkLmNpQrStUv'
const SUI_OWNER = `0x${'ab'.repeat(32)}`
const SUI_USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
-const WAL = '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL'
+const UNLISTED = '0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP'
const EVM_OWNER = `0x${'12'.repeat(20)}`
type Handler = (url: string, body: unknown) => unknown
@@ -184,11 +184,11 @@ describe('Sui balances', () => {
it('reads address.balances, naming an unlisted coin by its last segment', async () => {
stubFetch((_url, body) => {
const q = (body as { query: string }).query
- if (q.includes('coinMetadata')) return { data: { a0: { decimals: 9, symbol: 'WAL' } } }
+ if (q.includes('coinMetadata')) return { data: { a0: { decimals: 9, symbol: 'DEEP' } } }
return page([
node(`0x${'0'.repeat(63)}2::sui::SUI`, '1031937015'),
node(SUI_USDC, '304803'),
- node(WAL, '1500000'),
+ node(UNLISTED, '1500000'),
])
})
const result = await readSuiBalances(SUI_OWNER)
@@ -200,7 +200,7 @@ describe('Sui balances', () => {
expect.objectContaining({ symbol: 'USDC', amount: '0.304803', listed: true })
)
expect(result.tokens).toContainEqual(
- expect.objectContaining({ symbol: 'WAL', id: WAL, amount: '0.0015', listed: false })
+ expect.objectContaining({ symbol: 'DEEP', id: UNLISTED, amount: '0.0015', listed: false })
)
})
@@ -208,10 +208,10 @@ describe('Sui balances', () => {
stubFetch((_url, body) => {
const q = (body as { query: string }).query
if (q.includes('coinMetadata')) return { data: { a0: null } }
- return page([node(WAL, '1500000')])
+ return page([node(UNLISTED, '1500000')])
})
const result = await readSuiBalances(SUI_OWNER)
- expect(result.tokens.find((t) => t.id === WAL)).toBeUndefined()
+ expect(result.tokens.find((t) => t.id === UNLISTED)).toBeUndefined()
expect(result.complete).toBe(false)
})
@@ -246,7 +246,7 @@ describe('Sui balances', () => {
stubFetch((_url, body) => {
const q = (body as { query: string }).query
if (q.includes('coinMetadata')) return { data: { a0: null } }
- return page([node(WAL, '0')])
+ return page([node(UNLISTED, '0')])
})
const result = await readSuiBalances(SUI_OWNER)
expect(result.complete).toBe(true)
@@ -501,7 +501,7 @@ describe('chain balances route', () => {
it('is bounded to 20 seconds', async () => {
const route = await import('../../app/api/chain/[chain]/balances/route')
expect(route.maxDuration).toBe(20)
- })
+ }, 60_000)
})
describe('chainBalanceReply', () => {
diff --git a/apps/dapp/lib/__tests__/chain-beta.test.ts b/apps/dapp/lib/__tests__/chain-beta.test.ts
new file mode 100644
index 00000000..182fea48
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-beta.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ BETA_NOTICE_POINTS,
+ chainLabelWithBeta,
+ isBetaChain,
+ rememberBetaNotice,
+ shouldShowBetaNotice,
+} from '../chain-beta'
+
+function memoryStorage(): Pick {
+ const held = new Map()
+ return {
+ getItem: (key) => held.get(key) ?? null,
+ setItem: (key, value) => {
+ held.set(key, value)
+ },
+ }
+}
+
+const refusing: Pick = {
+ getItem: () => {
+ throw new Error('blocked')
+ },
+ setItem: () => {
+ throw new Error('blocked')
+ },
+}
+
+describe('which chains are in beta', () => {
+ it.each(['solana', 'sui', 'near', 'avalanche', 'monad'] as const)('%s is', (slug) => {
+ expect(isBetaChain(slug)).toBe(true)
+ })
+
+ it('Stellar is not', () => {
+ expect(isBetaChain('stellar')).toBe(false)
+ })
+
+ it('says so to a screen reader, on the beta chains only', () => {
+ expect(chainLabelWithBeta('monad', 'Monad mainnet')).toBe('Monad mainnet, beta')
+ expect(chainLabelWithBeta('stellar', 'Stellar mainnet')).toBe('Stellar mainnet')
+ })
+})
+
+describe('when the notice shows', () => {
+ it('shows once per chain', () => {
+ const storage = memoryStorage()
+ expect(shouldShowBetaNotice('solana', storage)).toBe(true)
+ rememberBetaNotice('solana', storage)
+ expect(shouldShowBetaNotice('solana', storage)).toBe(false)
+ expect(shouldShowBetaNotice('sui', storage)).toBe(true)
+ })
+
+ it('never shows on Stellar', () => {
+ expect(shouldShowBetaNotice('stellar', memoryStorage())).toBe(false)
+ })
+
+ it('does not come back on every page when the browser refuses storage', () => {
+ expect(shouldShowBetaNotice('solana', refusing)).toBe(false)
+ expect(() => rememberBetaNotice('solana', refusing)).not.toThrow()
+ })
+})
+
+describe('what the notice says', () => {
+ it('warns that a transaction can fail and costs a fee', () => {
+ expect(BETA_NOTICE_POINTS.join(' ')).toMatch(/transaction can fail.*network fee/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/chain-send.test.ts b/apps/dapp/lib/__tests__/chain-send.test.ts
index b40e6a91..87992e72 100644
--- a/apps/dapp/lib/__tests__/chain-send.test.ts
+++ b/apps/dapp/lib/__tests__/chain-send.test.ts
@@ -1151,33 +1151,7 @@ describe('prepareChainAction on a send', () => {
SOL_ME
)
)
- expect(said).toMatch(/only a send/i)
- })
-
- it('applies the dollar cap, in words for a transfer', async () => {
- const said = await refusalOf(
- prepareChainAction(
- moduleFor('solana'),
- request({ amount: '600', recipient: SOL_TO }),
- SOL_ME,
- {}
- )
- )
- expect(said).toMatch(/caps each transfer/)
- expect(said).not.toMatch(/trade/)
- expect(sim.rpc).toEqual([])
- })
-
- it('refuses a token it can’t price, in words for a transfer', async () => {
- const said = await refusalOf(
- prepareChainAction(
- moduleFor('solana', { priceUsd: async () => undefined }),
- request({ recipient: SOL_TO }),
- SOL_ME,
- {}
- )
- )
- expect(said).toMatch(/This transfer could not be valued/)
+ expect(said).toMatch(/doesn't take an address/i)
})
it('keeps the fee reserve on a native send', async () => {
@@ -1199,8 +1173,7 @@ describe('prepareChainAction on a send', () => {
}),
}),
request({ recipient: EVM_TO, tokenIn: 'AVAX' }),
- EVM_ME,
- {}
+ EVM_ME
)
)
expect(said).toMatch(/Keep about 0\.01 AVAX/)
@@ -1216,7 +1189,7 @@ describe('prepareChainAction on a send', () => {
listed: true,
})
const send = (amount: string, chain: ChainModule): Promise =>
- prepareChainAction(chain, request({ amount, recipient: EVM_TO }), EVM_ME, {})
+ prepareChainAction(chain, request({ amount, recipient: EVM_TO }), EVM_ME)
it('refuses a known shortfall in a plain sentence', async () => {
const chain = moduleFor('avalanche', {
@@ -1284,8 +1257,7 @@ describe('prepareChainAction on a send', () => {
await prepareChainAction(
moduleFor('avalanche', { balances }),
request({ tokenIn: 'AVAX', recipient: EVM_TO }),
- EVM_ME,
- {}
+ EVM_ME
)
expect(balances).toHaveBeenCalledTimes(1)
})
@@ -1302,8 +1274,7 @@ describe('prepareChainAction on a send', () => {
await prepareChainAction(
moduleFor('avalanche', { balances, venues: [venue], defaultVenue: { supply: 'lend' } }),
{ kind: 'supply', amount: '50', tokenIn: 'USDC' },
- EVM_ME,
- {}
+ EVM_ME
)
expect(balances).not.toHaveBeenCalled()
})
@@ -1315,8 +1286,7 @@ describe('prepareChainAction on a send', () => {
prepareChainAction(
moduleFor('solana', { balances }),
request({ recipient: SOL_TO, tokenIn: 'SOL' }),
- SOL_ME,
- {}
+ SOL_ME
)
)
expect(said).toContain('Sending SOL')
@@ -1327,8 +1297,7 @@ describe('prepareChainAction on a send', () => {
const prepared: PreparedAction = await prepareChainAction(
moduleFor('avalanche'),
request({ recipient: EVM_TO }),
- EVM_ME,
- {}
+ EVM_ME
)
expect(prepared.kind).toBe('send')
expect(prepared.context?.['recipient']).toBe(EVM_TO)
diff --git a/apps/dapp/lib/__tests__/chain-sequence.test.ts b/apps/dapp/lib/__tests__/chain-sequence.test.ts
index abfbe460..92c53e96 100644
--- a/apps/dapp/lib/__tests__/chain-sequence.test.ts
+++ b/apps/dapp/lib/__tests__/chain-sequence.test.ts
@@ -191,7 +191,8 @@ describe('routeCashout with an amount', () => {
token: 'SOL',
})
expect(routeCashout({ unsupportedToken: 'SOL', amount: '0.5' }, 'monad')).toEqual({
- kind: 'pass',
+ kind: 'token',
+ token: 'SOL',
})
})
@@ -203,7 +204,7 @@ describe('routeCashout with an amount', () => {
it('says on NEAR only what is true there', () => {
const reply = unsupportedTokenReply('SOL', 'near')
expect(reply).toMatch(/NEAR/)
- expect(reply).not.toMatch(/then cash out/i)
+ expect(reply).not.toMatch(/Swap your SOL/i)
expect(unsupportedTokenReply('SOL', 'solana')).toMatch(/Swap your SOL to USDC first/)
})
})
@@ -250,10 +251,10 @@ describe('followOnRefusal', () => {
it('names why a cash-out cannot run', () => {
expect(followOnRefusal('near', 'NEAR', { kind: 'cashout' }, swapVenue)).toMatch(
- /Weave.*NEAR deposits.*unavailable/
+ /don't run from NEAR.*Weave can't open an order/
)
expect(followOnRefusal('monad', 'Monad', { kind: 'cashout' }, swapVenue)).toMatch(
- /don't run on Monad/
+ /don't run from Monad.*Weave doesn't list Monad/
)
expect(followOnRefusal('solana', 'Solana', { kind: 'cashout' }, swapVenue)).toBeUndefined()
})
@@ -541,7 +542,8 @@ describe('chainHelp in the new forms', () => {
expect(help('near', 'NEAR')).not.toContain('then cash out')
expect(help('near', 'NEAR')).not.toContain('then stake')
expect(help('monad', 'Monad')).toContain('"swap 5 MON to USDC then send to an address"')
- expect(help('monad', 'Monad')).not.toMatch(/then (cash out|deposit)/)
+ expect(help('monad', 'Monad')).toContain('"swap 5 MON to USDC then deposit"')
+ expect(help('monad', 'Monad')).not.toContain('then cash out')
expect(help('monad', 'Monad')).toContain('"send 5 USDC to an address"')
})
})
diff --git a/apps/dapp/lib/__tests__/curvance.live.test.ts b/apps/dapp/lib/__tests__/curvance.live.test.ts
new file mode 100644
index 00000000..38a2a848
--- /dev/null
+++ b/apps/dapp/lib/__tests__/curvance.live.test.ts
@@ -0,0 +1,203 @@
+import {
+ encodeAbiParameters,
+ encodeFunctionData,
+ keccak256,
+ parseAbi,
+ toHex,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { evmReader } from '../chains/evm/client'
+import {
+ CURVANCE_CUSDC,
+ CURVANCE_MARKET_MANAGER,
+ CURVANCE_USDC,
+ curvanceVenue,
+} from '../chains/evm/curvance'
+
+/**
+ * The real contracts on Monad, read-only: nothing signs or sends. A withdrawal is only built and
+ * tried for Curvance's DAO multisig, which holds cUSDC and whose key nobody here has. When it leaves
+ * the market that case fails on balance, so swap it for another holder.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const REGISTRY = '0x1310f352f1389969Ece6741671c4B919523912fF'
+const DAO = '0x0Acb7eF4D8733C719d60e0992B489b629bc55C02'
+const EMERGENCY_COUNCIL = '0x379D4a8FBc23A8Fd8c2b3738Dbf1fEBe9a64399c'
+const RAPID_RESPONSE = '0x25D4134861b30Ba7215d2A280DD84C57c5780902'
+const RAPID_RESPONSE_PAUSER = '0x89D2253b13c718d06B78fCcE3d973a520bD07803'
+const THROWAWAY = '0x00000000000000000000000000000000000abcde'
+const EIP1967_IMPLEMENTATION = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbd'
+const EMPTY_SLOT = `0x${'0'.repeat(64)}`
+// Circle's FiatToken keeps balances at slot 9 and allowances at slot 10.
+const USDC_BALANCE_SLOT = BigInt(9)
+const USDC_ALLOWANCE_SLOT = BigInt(10)
+
+const CTOKEN = parseAbi([
+ 'function asset() view returns (address)',
+ 'function marketManager() view returns (address)',
+ 'function decimals() view returns (uint8)',
+ 'function symbol() view returns (string)',
+ 'function centralRegistry() view returns (address)',
+ 'function MAX_INTEREST_ACCRUAL_FEE() view returns (uint256)',
+ 'function deposit(uint256 assets, address receiver) returns (uint256)',
+])
+const MANAGER = parseAbi([
+ 'function isListed(address cToken) view returns (bool)',
+ 'function queryTokensListed() view returns (address[])',
+ 'function MIN_HOLD_PERIOD() view returns (uint256)',
+])
+const SAFE = parseAbi(['function getThreshold() view returns (uint256)'])
+const PAUSER = parseAbi(['function owner() view returns (address)'])
+const REGISTRY_ABI = parseAbi([
+ 'function hasMarketPermissions(address account) view returns (bool)',
+ 'function emergencyCouncil() view returns (address)',
+])
+
+const reader = evmReader('monad')
+
+function mappingSlot(key: Address, slot: bigint): Hex {
+ return keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key, slot]))
+}
+
+function allowanceSlot(owner: Address, spender: Address): Hex {
+ const inner = mappingSlot(owner, USDC_ALLOWANCE_SLOT)
+ return keccak256(
+ encodeAbiParameters([{ type: 'address' }, { type: 'bytes32' }], [spender, inner])
+ )
+}
+
+describe.skipIf(SKIP)('the pinned Curvance contracts', () => {
+ it('has code at each', async () => {
+ for (const address of [CURVANCE_CUSDC, CURVANCE_MARKET_MANAGER, CURVANCE_USDC]) {
+ const code = await reader.getCode({ address: address as Address })
+ expect(code?.length ?? 0, address).toBeGreaterThan(2)
+ }
+ }, 30_000)
+
+ it('holds the market together as the docs say', async () => {
+ const cToken = { address: CURVANCE_CUSDC as Address, abi: CTOKEN }
+ const manager = { address: CURVANCE_MARKET_MANAGER as Address, abi: MANAGER }
+ expect(await reader.readContract({ ...cToken, functionName: 'asset' })).toBe(CURVANCE_USDC)
+ expect(await reader.readContract({ ...cToken, functionName: 'marketManager' })).toBe(
+ CURVANCE_MARKET_MANAGER
+ )
+ expect(await reader.readContract({ ...cToken, functionName: 'decimals' })).toBe(6)
+ expect(await reader.readContract({ ...cToken, functionName: 'symbol' })).toBe('cUSDC')
+ expect(await reader.readContract({ ...cToken, functionName: 'centralRegistry' })).toBe(REGISTRY)
+ expect(
+ await reader.readContract({ ...manager, functionName: 'isListed', args: [CURVANCE_CUSDC] })
+ ).toBe(true)
+ expect(await reader.readContract({ ...manager, functionName: 'queryTokensListed' })).toContain(
+ CURVANCE_CUSDC
+ )
+ }, 30_000)
+
+ it('still has the 20 minute hold the card states', async () => {
+ const hold = await reader.readContract({
+ address: CURVANCE_MARKET_MANAGER,
+ abi: MANAGER,
+ functionName: 'MIN_HOLD_PERIOD',
+ })
+ expect(hold).toBe(BigInt(1200))
+ }, 30_000)
+
+ it('still has the admins the card states', async () => {
+ const threshold = (address: string) =>
+ reader.readContract({ address: address as Address, abi: SAFE, functionName: 'getThreshold' })
+ expect(
+ await reader.readContract({
+ address: CURVANCE_CUSDC as Address,
+ abi: CTOKEN,
+ functionName: 'MAX_INTEREST_ACCRUAL_FEE',
+ })
+ ).toBe(BigInt(6000))
+ expect(await threshold(RAPID_RESPONSE)).toBe(BigInt(1))
+ expect(await threshold(EMERGENCY_COUNCIL)).toBe(BigInt(4))
+ expect(
+ await reader.readContract({
+ address: RAPID_RESPONSE_PAUSER,
+ abi: PAUSER,
+ functionName: 'owner',
+ })
+ ).toBe(RAPID_RESPONSE)
+ expect(
+ await reader.readContract({
+ address: REGISTRY,
+ abi: REGISTRY_ABI,
+ functionName: 'hasMarketPermissions',
+ args: [RAPID_RESPONSE_PAUSER],
+ })
+ ).toBe(true)
+ expect(
+ await reader.readContract({
+ address: REGISTRY,
+ abi: REGISTRY_ABI,
+ functionName: 'emergencyCouncil',
+ })
+ ).toBe(EMERGENCY_COUNCIL)
+ }, 30_000)
+
+ it('is not behind a standard upgrade proxy', async () => {
+ for (const address of [CURVANCE_CUSDC, CURVANCE_MARKET_MANAGER]) {
+ const slot = await reader.getStorageAt({
+ address: address as Address,
+ slot: EIP1967_IMPLEMENTATION,
+ })
+ expect(slot ?? EMPTY_SLOT, address).toBe(EMPTY_SLOT)
+ }
+ }, 30_000)
+})
+
+describe.skipIf(SKIP)('Curvance against the live market', () => {
+ it('reads a real rate for the race', async () => {
+ const quote = await curvanceVenue.quote?.(
+ { kind: 'supply', amount: '1', tokenIn: 'USDC', venue: 'curvance' },
+ THROWAWAY
+ )
+ expect(quote?.apyPercent).toBeGreaterThan(0)
+ expect(quote?.apyPercent).toBeLessThan(200)
+ }, 30_000)
+
+ it('accepts its deposit from an account given USDC and an allowance', async () => {
+ const account = THROWAWAY as Address
+ const spender = CURVANCE_CUSDC as Address
+ const value = toHex(BigInt(1_000_000_000), { size: 32 })
+ const units = BigInt(100_000_000)
+ const { data } = await reader.call({
+ account,
+ to: spender,
+ data: encodeFunctionData({ abi: CTOKEN, functionName: 'deposit', args: [units, account] }),
+ stateOverride: [
+ {
+ address: CURVANCE_USDC as Address,
+ stateDiff: [
+ { slot: mappingSlot(account, USDC_BALANCE_SLOT), value },
+ { slot: allowanceSlot(account, spender), value },
+ ],
+ },
+ ],
+ })
+ expect(BigInt(data ?? '0x0')).toBeGreaterThan(BigInt(0))
+ }, 30_000)
+
+ it('builds a withdrawal a real holder could send, and it passes its own check', async () => {
+ const prepared = await curvanceVenue.prepare(
+ { kind: 'withdraw', amount: '1', tokenIn: 'USDC', venue: 'curvance' },
+ DAO
+ )
+ expect(() => curvanceVenue.assertSafe(prepared.signing, DAO)).not.toThrow()
+ }, 30_000)
+
+ it('refuses a withdrawal for an account that lent nothing', async () => {
+ await expect(
+ curvanceVenue.prepare(
+ { kind: 'withdraw', amount: '1', tokenIn: 'USDC', venue: 'curvance' },
+ THROWAWAY
+ )
+ ).rejects.toThrow(/no USDC lent/)
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/curvance.test.ts b/apps/dapp/lib/__tests__/curvance.test.ts
new file mode 100644
index 00000000..3c4dc6a1
--- /dev/null
+++ b/apps/dapp/lib/__tests__/curvance.test.ts
@@ -0,0 +1,606 @@
+import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import type { EvmReaderClient } from '../chains/evm/call'
+import type { EvmClient, EvmReceipt } from '../chains/evm/client'
+import {
+ createCurvanceVenue,
+ CURVANCE_CUSDC,
+ CURVANCE_MARKET_MANAGER,
+ CURVANCE_USDC,
+} from '../chains/evm/curvance'
+import { curvanceListing } from '../chains/evm/curvance-listing'
+import { MONAD_TOKENS } from '../chains/evm/tokens'
+import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types'
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111'
+const STRANGER = '0x2222222222222222222222222222222222222222'
+/** Curvance's cUSDC in its WBTC market: real, but not the one this venue is confirmed for. */
+const OTHER_MARKET = '0x7C9d4f1695C6282Da5e5509Aa51fC9fb417C6f1d'
+const USDT0 = '0xe7cd86e13AC4309349F30B3435a9d337750fC82D'
+const MONAD_ID = 143
+const HASH = `0x${'a'.repeat(64)}`
+const NOW = BigInt(1_800_000_000)
+
+const CALLS = parseAbi([
+ 'function deposit(uint256 assets, address receiver) returns (uint256)',
+ 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)',
+ 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
+ 'function depositAsCollateral(uint256 assets, address receiver) returns (uint256)',
+ 'function withdrawCollateral(uint256 assets, address receiver, address owner) returns (uint256)',
+ 'function borrow(uint256 assets, address receiver)',
+ 'function repay(uint256 assets)',
+ 'function transfer(address to, uint256 amount) returns (bool)',
+])
+
+function deposit(assets = BigInt(5_000_000), receiver: string = ACCOUNT): string {
+ return encodeFunctionData({
+ abi: CALLS,
+ functionName: 'deposit',
+ args: [assets, receiver as Address],
+ })
+}
+
+function withdraw(
+ assets = BigInt(5_000_000),
+ receiver: string = ACCOUNT,
+ owner: string = ACCOUNT
+): string {
+ return encodeFunctionData({
+ abi: CALLS,
+ functionName: 'withdraw',
+ args: [assets, receiver as Address, owner as Address],
+ })
+}
+
+function redeem(
+ shares = BigInt(5_000_000),
+ receiver: string = ACCOUNT,
+ owner: string = ACCOUNT
+): string {
+ return encodeFunctionData({
+ abi: CALLS,
+ functionName: 'redeem',
+ args: [shares, receiver as Address, owner as Address],
+ })
+}
+
+function approve(spender: string = CURVANCE_CUSDC, units = BigInt(5_000_000)): string {
+ return encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spender as Address, units],
+ })
+}
+
+function payload(overrides: Partial> = {}) {
+ return {
+ family: 'evm' as const,
+ chainId: MONAD_ID,
+ to: CURVANCE_CUSDC,
+ data: deposit(),
+ value: '0',
+ ...overrides,
+ }
+}
+
+interface ChainState {
+ usdc: bigint
+ allowance: bigint
+ shares: bigint
+ posted: bigint
+ held: bigint
+ debt: bigint
+ mintPaused: boolean
+ listed: boolean
+ redeemPaused: number
+ cooldown: bigint
+ rate: bigint | Error
+ callFails: boolean
+ readFails: string | undefined
+}
+
+const HEALTHY: ChainState = {
+ usdc: BigInt(100_000_000),
+ allowance: BigInt(0),
+ shares: BigInt(50_000_000),
+ posted: BigInt(0),
+ held: BigInt(30_000_000_000),
+ debt: BigInt(270_000_000_000),
+ mintPaused: false,
+ listed: true,
+ redeemPaused: 1,
+ cooldown: BigInt(0),
+ // 10% a year, per second, in WAD.
+ rate: BigInt(3_170_979_198),
+ callFails: false,
+ readFails: undefined,
+}
+
+interface FakeRead {
+ address: string
+ functionName: string
+ args?: readonly unknown[]
+}
+
+/** The rate of the real market, one cUSDC to 1.04 USDC, so shares and assets differ as they do live. */
+function fakeReader(overrides: Partial = {}): EvmReaderClient & { calls: unknown[] } {
+ const state = { ...HEALTHY, ...overrides }
+ const calls: unknown[] = []
+ const reads: Record unknown> = {
+ balanceOf: (_args, address) => (address === CURVANCE_CUSDC ? state.shares : state.usdc),
+ allowance: () => state.allowance,
+ collateralPosted: () => state.posted,
+ previewDeposit: ([assets]) => ((assets as bigint) * BigInt(100)) / BigInt(104),
+ previewWithdraw: ([assets]) => ((assets as bigint) * BigInt(100)) / BigInt(104) + BigInt(1),
+ exchangeRateUpdated: () => BigInt('1040000000000000000'),
+ assetsHeld: () => state.held,
+ marketOutstandingDebt: () => state.debt,
+ interestFee: () => 1000,
+ IRM: () => '0x1622D77E311638C77779e6D9B212E3e7D73AaFB4',
+ actionsPaused: () => [state.mintPaused, false, false],
+ isListed: () => state.listed,
+ redeemPaused: () => state.redeemPaused,
+ accountAssets: () => state.cooldown,
+ supplyRate: () => {
+ if (state.rate instanceof Error) throw state.rate
+ return state.rate
+ },
+ }
+ const readContract = async ({ address, functionName, args = [] }: FakeRead) => {
+ if (state.readFails === functionName) throw new Error('rpc down')
+ const read = reads[functionName]
+ if (read === undefined) throw new Error(`unexpected read ${functionName}`)
+ return read(args, address)
+ }
+ return {
+ calls,
+ readContract,
+ multicall: async ({ contracts }: { contracts: FakeRead[] }) =>
+ Promise.all(contracts.map(readContract)),
+ call: async (request: unknown) => {
+ calls.push(request)
+ if (state.callFails) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ getBlock: async () => ({ timestamp: NOW }),
+ } as unknown as EvmReaderClient & { calls: unknown[] }
+}
+
+function supply(amount = '5', tokenIn = 'USDC'): ChainActionRequest {
+ return { kind: 'supply', amount, tokenIn, venue: 'curvance' }
+}
+
+function takeOut(amount = '5', tokenIn = 'USDC'): ChainActionRequest {
+ return { kind: 'withdraw', amount, tokenIn, venue: 'curvance' }
+}
+
+function venue(overrides: Partial = {}) {
+ return createCurvanceVenue(() => fakeReader(overrides))
+}
+
+function evmSigning(signing: SigningPayload) {
+ if (signing.family !== 'evm') throw new Error('expected evm')
+ return signing
+}
+
+describe('pinned ids', () => {
+ it('uses the same USDC as the chain table', () => {
+ expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')?.id).toBe(CURVANCE_USDC)
+ })
+
+ it('offers only what it builds', () => {
+ expect(venue().kinds).toEqual(['supply', 'withdraw'])
+ })
+
+ it('lists the venue as executing on Monad', () => {
+ expect(curvanceListing).toMatchObject({
+ id: 'curvance',
+ family: 'evm',
+ category: 'lending',
+ chains: ['Monad'],
+ integration: 'executes',
+ })
+ })
+})
+
+describe('assertSafe', () => {
+ const safe = venue()
+
+ it('passes the deposit and withdrawal it builds', () => {
+ expect(() => safe.assertSafe(payload(), ACCOUNT)).not.toThrow()
+ expect(() => safe.assertSafe(payload({ data: withdraw() }), ACCOUNT)).not.toThrow()
+ })
+
+ it('passes a deposit with its exact approval', () => {
+ const signing = payload({ approval: { to: CURVANCE_USDC, data: approve() } })
+ expect(() => safe.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('refuses a deposit the wrong account would sign', () => {
+ expect(() => safe.assertSafe(payload(), STRANGER)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a withdrawal the wrong account would sign', () => {
+ expect(() => safe.assertSafe(payload({ data: withdraw() }), STRANGER)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', payload({ chainId: 43114 })],
+ ['an unknown contract', payload({ to: STRANGER })],
+ ["cUSDC in another of Curvance's markets", payload({ to: OTHER_MARKET })],
+ ['the market manager', payload({ to: CURVANCE_MARKET_MANAGER })],
+ ['native value', payload({ value: '1' })],
+ ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })],
+ ['extra calldata on a deposit', payload({ data: `${deposit()}00` })],
+ ['extra calldata on a withdrawal', payload({ data: `${withdraw()}00` })],
+ ['a deposit credited to someone else', payload({ data: deposit(BigInt(5), STRANGER) })],
+ ['a withdrawal paid to someone else', payload({ data: withdraw(BigInt(5), STRANGER) })],
+ [
+ "a withdrawal of someone else's shares",
+ payload({ data: withdraw(BigInt(5), ACCOUNT, STRANGER) }),
+ ],
+ ['an empty deposit', payload({ data: deposit(BigInt(0)) })],
+ ['an empty withdrawal', payload({ data: withdraw(BigInt(0)) })],
+ [
+ 'an unlimited approval',
+ payload({ approval: { to: CURVANCE_USDC, data: approve(CURVANCE_CUSDC, maxUint256) } }),
+ ],
+ [
+ 'an approval larger than the deposit',
+ payload({
+ approval: { to: CURVANCE_USDC, data: approve(CURVANCE_CUSDC, BigInt(6_000_000)) },
+ }),
+ ],
+ [
+ 'an approval smaller than the deposit',
+ payload({
+ approval: { to: CURVANCE_USDC, data: approve(CURVANCE_CUSDC, BigInt(4_000_000)) },
+ }),
+ ],
+ [
+ 'an approval to a stranger',
+ payload({ approval: { to: CURVANCE_USDC, data: approve(STRANGER) } }),
+ ],
+ ['an approval of the wrong token', payload({ approval: { to: USDT0, data: approve() } })],
+ [
+ 'an approval with trailing data',
+ payload({ approval: { to: CURVANCE_USDC, data: `${approve()}00` } }),
+ ],
+ [
+ 'a withdrawal carrying an approval',
+ payload({ data: withdraw(), approval: { to: CURVANCE_USDC, data: approve() } }),
+ ],
+ [
+ 'a first step that is a transfer',
+ payload({
+ approval: {
+ to: CURVANCE_USDC,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [STRANGER as Address, BigInt(5_000_000)],
+ }),
+ },
+ }),
+ ],
+ ])('refuses %s', (_label, signing) => {
+ expect(() => safe.assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('passes a redeem of the shares, to and from the person', () => {
+ expect(() => safe.assertSafe(payload({ data: redeem() }), ACCOUNT)).not.toThrow()
+ expect(() => safe.assertSafe(payload({ data: redeem() }), STRANGER)).toThrow(ChainActionRefused)
+ })
+
+ it.each<[string, string]>([
+ ['paying someone else', redeem(BigInt(5), STRANGER)],
+ ['from someone else', redeem(BigInt(5), ACCOUNT, STRANGER)],
+ ['of nothing', redeem(BigInt(0))],
+ ['naming no amount', redeem(maxUint256)],
+ ['with extra data', `${redeem()}00`],
+ ])('refuses a redeem %s', (_label, data) => {
+ expect(() => safe.assertSafe(payload({ data }), ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses an approval beside a redeem', () => {
+ const signing = payload({ data: redeem(), approval: { to: CURVANCE_USDC, data: approve() } })
+ expect(() => safe.assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it.each<[string, string]>([
+ [
+ 'depositAsCollateral',
+ encodeFunctionData({
+ abi: CALLS,
+ functionName: 'depositAsCollateral',
+ args: [BigInt(5), ACCOUNT as Address],
+ }),
+ ],
+ [
+ 'withdrawCollateral',
+ encodeFunctionData({
+ abi: CALLS,
+ functionName: 'withdrawCollateral',
+ args: [BigInt(5), ACCOUNT as Address, ACCOUNT as Address],
+ }),
+ ],
+ [
+ 'borrow',
+ encodeFunctionData({
+ abi: CALLS,
+ functionName: 'borrow',
+ args: [BigInt(5), ACCOUNT as Address],
+ }),
+ ],
+ ['repay', encodeFunctionData({ abi: CALLS, functionName: 'repay', args: [BigInt(0)] })],
+ [
+ 'transfer',
+ encodeFunctionData({
+ abi: CALLS,
+ functionName: 'transfer',
+ args: [STRANGER as Address, BigInt(5)],
+ }),
+ ],
+ ])('refuses the unconfirmed function %s', (_label, data) => {
+ expect(() => safe.assertSafe(payload({ data }), ACCOUNT)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('supply', () => {
+ it('builds a plain deposit to the person with an exact approval', async () => {
+ const prepared = await venue().prepare(supply(), ACCOUNT)
+ expect(prepared).toMatchObject({
+ venueId: 'curvance',
+ kind: 'supply',
+ pay: { amount: '5', symbol: 'USDC' },
+ receive: { symbol: 'cUSDC', approximate: true },
+ })
+ const signing = evmSigning(prepared.signing)
+ expect(signing.to).toBe(CURVANCE_CUSDC)
+ expect(signing.data).toBe(deposit())
+ expect(signing.value).toBe('0')
+ expect(signing.approval).toEqual({ to: CURVANCE_USDC, data: approve() })
+ })
+
+ it('leaves the approval off when one is in place, and tries the call', async () => {
+ const reader = fakeReader({ allowance: BigInt(5_000_000) })
+ const prepared = await createCurvanceVenue(() => reader).prepare(supply(), ACCOUNT)
+ expect(evmSigning(prepared.signing).approval).toBeUndefined()
+ expect(reader.calls).toHaveLength(1)
+ expect(prepared.notes.join(' ')).not.toContain('asks twice')
+ })
+
+ it('says the wallet asks twice when it does', async () => {
+ const prepared = await venue().prepare(supply(), ACCOUNT)
+ expect(prepared.notes.join(' ')).toContain('exactly 5 USDC')
+ })
+
+ it('reads the rate from the chain as a yearly figure, with no reward part', async () => {
+ const prepared = await venue().prepare(supply(), ACCOUNT)
+ expect(prepared.apy?.basePercent).toBeCloseTo(10, 1)
+ expect(prepared.apy?.rewardPercent).toBeUndefined()
+ })
+
+ it('shows no rate when it cannot be read, rather than a guess', async () => {
+ const prepared = await venue({ rate: new Error('rpc') }).prepare(supply(), ACCOUNT)
+ expect(prepared.apy).toBeUndefined()
+ })
+
+ it('tells the person about the hold, the market, the admins and what is free', async () => {
+ const { notes } = await venue().prepare(supply(), ACCOUNT)
+ const text = notes.join(' ')
+ expect(text).toContain('WMON')
+ expect(text).toContain('20 minutes')
+ expect(text).toMatch(/pause/i)
+ expect(text).toContain('30000 USDC')
+ expect(text).not.toMatch(/—/)
+ })
+
+ it.each<[string, ChainActionRequest, Partial]>([
+ ['another token', supply('5', 'USDT0'), {}],
+ ['the native coin', supply('5', 'MON'), {}],
+ ['a zero amount', supply('0'), {}],
+ ['more decimals than USDC has', supply('1.0000001'), {}],
+ ['more than the wallet holds', supply(), { usdc: BigInt(4_999_999) }],
+ ['a market with deposits paused', supply(), { mintPaused: true }],
+ ['a token no longer listed', supply(), { listed: false }],
+ ['an amount too small to mint a share', supply('0.000001'), {}],
+ ['an unreadable pause flag', supply(), { readFails: 'actionsPaused' }],
+ ['an unreadable balance', supply(), { readFails: 'balanceOf' }],
+ ['a call that would fail', supply(), { allowance: BigInt(5_000_000), callFails: true }],
+ ])('refuses %s', async (_label, request, state) => {
+ await expect(venue(state).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses an account that is not an address', async () => {
+ await expect(venue().prepare(supply(), 'not-an-address')).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('quote', () => {
+ it('gives the rate and calls nothing', async () => {
+ const reader = fakeReader()
+ const quote = await createCurvanceVenue(() => reader).quote?.(supply(), ACCOUNT)
+ expect(quote?.apyPercent).toBeCloseTo(10, 1)
+ expect(reader.calls).toHaveLength(0)
+ })
+
+ it('gives no rate when it cannot be read', async () => {
+ const quote = await venue({ rate: new Error('rpc') }).quote?.(supply(), ACCOUNT)
+ expect(quote).toEqual({})
+ })
+
+ it('refuses a paused market', async () => {
+ await expect(venue({ mintPaused: true }).quote?.(supply(), ACCOUNT)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+})
+
+describe('withdraw', () => {
+ it('builds a withdrawal to and from the person, with no approval', async () => {
+ const reader = fakeReader()
+ const prepared = await createCurvanceVenue(() => reader).prepare(takeOut(), ACCOUNT)
+ expect(prepared).toMatchObject({
+ venueId: 'curvance',
+ kind: 'withdraw',
+ pay: { amount: '5', symbol: 'USDC' },
+ })
+ const signing = evmSigning(prepared.signing)
+ expect(signing.to).toBe(CURVANCE_CUSDC)
+ expect(signing.data).toBe(withdraw())
+ expect(signing.approval).toBeUndefined()
+ expect(reader.calls).toHaveLength(1)
+ })
+
+ it('refuses during the hold, saying how long is left', async () => {
+ const cooldown = NOW - BigInt(300)
+ await expect(venue({ cooldown }).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/15 minutes/)
+ })
+
+ it('goes ahead the moment the hold ends', async () => {
+ const cooldown = NOW - BigInt(1200)
+ await expect(venue({ cooldown }).prepare(takeOut(), ACCOUNT)).resolves.toMatchObject({
+ kind: 'withdraw',
+ })
+ })
+
+ it('never takes posted collateral, which could weaken a loan', async () => {
+ const state = { shares: BigInt(10_000_000), posted: BigInt(8_000_000) }
+ await expect(venue(state).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/collateral/)
+ })
+
+ it('takes idle shares while collateral stays posted', async () => {
+ const state = { shares: BigInt(10_000_000), posted: BigInt(2_000_000) }
+ const prepared = await venue(state).prepare(takeOut(), ACCOUNT)
+ expect(evmSigning(prepared.signing).data).toBe(withdraw())
+ })
+
+ it('says what is there when asked for more', async () => {
+ const state = { shares: BigInt(1_000_000) }
+ await expect(venue(state).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/1\.04 USDC/)
+ })
+
+ it('says what the market has free when it has too little', async () => {
+ const state = { held: BigInt(4_000_000) }
+ await expect(venue(state).prepare(takeOut(), ACCOUNT)).rejects.toThrow(/4 USDC/)
+ })
+
+ it.each<[string, ChainActionRequest, Partial]>([
+ ['another token', takeOut('5', 'USDT0'), {}],
+ ['a zero amount', takeOut('0'), {}],
+ ['no position', takeOut(), { shares: BigInt(0) }],
+ ['a market with withdrawals paused', takeOut(), { redeemPaused: 2 }],
+ ['an unknown pause flag', takeOut(), { redeemPaused: 0 }],
+ ['a token no longer listed', takeOut(), { listed: false }],
+ ['an unreadable hold', takeOut(), { readFails: 'accountAssets' }],
+ ['unreadable collateral', takeOut(), { readFails: 'collateralPosted' }],
+ ['a call that would fail', takeOut(), { callFails: true }],
+ ])('refuses %s', async (_label, request, state) => {
+ await expect(venue(state).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses the kinds it does not offer', async () => {
+ const borrow: ChainActionRequest = { kind: 'borrow', amount: '5', tokenIn: 'USDC' }
+ await expect(venue().prepare(borrow, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('exits', () => {
+ const whole: ChainActionRequest = { ...takeOut(), wholePosition: true }
+
+ it('shows everything here at the live rate, posted collateral included', async () => {
+ const state = { shares: BigInt(10_000_000), posted: BigInt(8_000_000) }
+ await expect(venue(state).positions?.(ACCOUNT)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '10.4' },
+ ])
+ })
+
+ it('shows nothing with no shares', async () => {
+ await expect(venue({ shares: BigInt(0) }).positions?.(ACCOUNT)).resolves.toEqual([])
+ })
+
+ it.each(['balanceOf', 'exchangeRateUpdated'])(
+ 'throws when %s can not be read, rather than saying nothing is there',
+ async (readFails) => {
+ await expect(venue({ readFails }).positions?.(ACCOUNT)).rejects.toThrow()
+ }
+ )
+
+ it('redeems every share for all of it, to and from the person', async () => {
+ const safe = venue()
+ const prepared = await safe.prepare(whole, ACCOUNT)
+ const signing = evmSigning(prepared.signing)
+ expect(signing.data).toBe(redeem(BigInt(50_000_000)))
+ expect(signing.approval).toBeUndefined()
+ expect(prepared.pay).toEqual({ amount: '52', symbol: 'USDC' })
+ expect(() => safe.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('does not mistake interest since the figure was read for asking too much', async () => {
+ const prepared = await venue().prepare({ ...whole, amount: '52.000001' }, ACCOUNT)
+ expect(evmSigning(prepared.signing).data).toBe(redeem(BigInt(50_000_000)))
+ })
+
+ it.each<[string, Partial, RegExp]>([
+ ['nothing there', { shares: BigInt(0) }, /no USDC lent/],
+ ['withdrawals paused', { redeemPaused: 2 }, /paused.*still yours/],
+ ['a hold running', { cooldown: NOW }, /holding this account/],
+ [
+ 'collateral posted',
+ { shares: BigInt(10_000_000), posted: BigInt(8_000_000) },
+ /2.08 USDC of yours.*remove the collateral/,
+ ],
+ ['too little free', { held: BigInt(4_000_000) }, /only 4 USDC free/],
+ ['a call that would fail', { callFails: true }, /still yours/],
+ ])('refuses all of it with %s', async (_label, state, words) => {
+ const asked = venue(state).prepare(whole, ACCOUNT)
+ await expect(asked).rejects.toThrow(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+})
+
+function fakeClient(receipt: Partial, input: string = deposit()): EvmClient {
+ return {
+ tokenBalance: async () => BigInt(0),
+ nativeBalance: async () => BigInt(0),
+ waitForReceipt: async () =>
+ ({
+ status: 'success',
+ from: ACCOUNT,
+ to: CURVANCE_CUSDC,
+ logs: [],
+ ...receipt,
+ }) as EvmReceipt,
+ transaction: async () => ({ input, value: BigInt(0) }),
+ }
+}
+
+describe('submit', () => {
+ const signed = {
+ account: ACCOUNT,
+ venueId: 'curvance',
+ signed: { family: 'evm' as const, hash: HASH },
+ }
+
+ function submitWith(client: EvmClient) {
+ return createCurvanceVenue(() => fakeReader(), client).submit?.(signed)
+ }
+
+ it('settles a landed deposit from the person', async () => {
+ await expect(submitWith(fakeClient({}))).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it.each<[string, EvmClient]>([
+ ['another signer', fakeClient({ from: STRANGER })],
+ ['another market', fakeClient({ to: OTHER_MARKET })],
+ ['a withdrawal paid to someone else', fakeClient({}, withdraw(BigInt(5), STRANGER))],
+ ])('stays pending for %s', async (_label, client) => {
+ await expect(submitWith(client)).resolves.toMatchObject({ ok: false, pending: true })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/deepbook.live.test.ts b/apps/dapp/lib/__tests__/deepbook.live.test.ts
new file mode 100644
index 00000000..481ca9b7
--- /dev/null
+++ b/apps/dapp/lib/__tests__/deepbook.live.test.ts
@@ -0,0 +1,103 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, normalizeStructTag } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import { DEEP_TYPE, DEEPBOOK_PACKAGE, DEEPBOOK_POOLS, deepbookVenue } from '../chains/sui/deepbook'
+import { tokenType } from '../chains/sui/direct-swap'
+
+/**
+ * Read-only against mainnet: everything is built and dry-run, for a public address holding USDC
+ * and SUI whose key nobody here has. When that address empties the swaps fail on balance, so swap
+ * it for another funded one.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const ACCOUNT = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const REGISTRY = '0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d'
+const UPGRADE_CAP = '0xdadf253cea3b91010e64651b03da6d56166a4f44b43bdd4e185c277658634483'
+const USDC = tokenType('USDC')
+
+describe.skipIf(SKIP)('DeepBook against mainnet', () => {
+ it('still matches its upgrade cap, or the package needs a fresh look', async () => {
+ const { objects } = await suiClient().core.getObjects({
+ objectIds: [UPGRADE_CAP],
+ include: { json: true },
+ })
+ const [cap] = objects as unknown as [{ json?: { package?: string } }]
+ expect(cap.json?.package).toBe(DEEPBOOK_PACKAGE)
+ }, 30_000)
+
+ it.each(DEEPBOOK_POOLS.map((p) => [`${p.base}/${p.quote}`, p] as const))(
+ '%s is still the pool the registry names, of those coins',
+ async (_name, pool) => {
+ const typeArguments = [tokenType(pool.base), tokenType(pool.quote)]
+ const { objects } = await suiClient().core.getObjects({ objectIds: [pool.id] })
+ const [object] = objects as unknown as [
+ { type?: string; owner?: { Shared?: { initialSharedVersion: string } } },
+ ]
+ expect(object.type).toContain(`pool::Pool<${typeArguments[0]},${typeArguments[1]}>`)
+ expect(object.owner?.Shared?.initialSharedVersion).toBe(String(pool.initialSharedVersion))
+
+ const tx = new Transaction()
+ tx.setSender(ACCOUNT)
+ tx.moveCall({
+ target: `${DEEPBOOK_PACKAGE}::pool::get_pool_id_by_asset`,
+ typeArguments,
+ arguments: [tx.object(REGISTRY)],
+ })
+ const result = await suiClient().core.simulateTransaction({
+ transaction: tx,
+ checksEnabled: false,
+ include: { commandResults: true },
+ })
+ const id = result.commandResults?.[0]?.returnValues[0]?.bcs
+ expect(id && bcs.Address.parse(id)).toBe(pool.id)
+ },
+ 30_000
+ )
+
+ it('still names the DEEP coin this file pins in its swap signature', async () => {
+ const shape = await suiClient().core.getMoveFunction({
+ packageId: DEEPBOOK_PACKAGE,
+ moduleName: 'pool',
+ name: 'swap_exact_base_for_quote',
+ })
+ expect(JSON.stringify(shape)).toContain(normalizeStructTag(DEEP_TYPE))
+ }, 30_000)
+
+ it('quotes USDC for SUI, builds it, passes the check, and the dry run pays near the quote', async () => {
+ const request = { kind: 'swap', amount: '20', tokenIn: 'SUI', tokenOut: 'USDC' } as const
+ const quoted = await deepbookVenue.quote?.(request, ACCOUNT)
+ const prepared = await deepbookVenue.prepare(request, ACCOUNT)
+ expect(() => deepbookVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { balanceChanges: true, effects: true },
+ })
+ expect(result.Transaction?.effects?.status.success).toBe(true)
+ const paid = result.Transaction?.balanceChanges?.find(
+ (c) => normalizeStructTag(c.coinType) === USDC && c.address === ACCOUNT
+ )
+ const got = Number(paid?.amount) / 1e6
+ expect(got).toBeGreaterThan(Number(quoted?.receive?.amount) * 0.995)
+ expect(got).toBeLessThan(Number(quoted?.receive?.amount) * 1.005)
+ }, 60_000)
+
+ it('builds SUI for USDC the other way, and the dry run passes', async () => {
+ const prepared = await deepbookVenue.prepare(
+ { kind: 'swap', amount: '20', tokenIn: 'USDC', tokenOut: 'SUI' },
+ ACCOUNT
+ )
+ expect(() => deepbookVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { effects: true },
+ })
+ expect(result.Transaction?.effects?.status.success).toBe(true)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/deepbook.test.ts b/apps/dapp/lib/__tests__/deepbook.test.ts
new file mode 100644
index 00000000..29dfcf63
--- /dev/null
+++ b/apps/dapp/lib/__tests__/deepbook.test.ts
@@ -0,0 +1,433 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, toBase64 } from '@mysten/sui/utils'
+import { beforeAll, describe, expect, it } from 'vitest'
+
+import {
+ createDeepbookVenue,
+ DEEP_TYPE,
+ DEEPBOOK_PACKAGE,
+ DEEPBOOK_POOLS,
+} from '../chains/sui/deepbook'
+import { deepbookListing } from '../chains/sui/deepbook-listing'
+import type { SwapIo } from '../chains/sui/direct-swap'
+import { ChainActionRefused, type ChainActionRequest } from '../chains/types'
+
+const ME = '0x00000000000000000000000000000000000000000000000000000000000000a1'
+const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead'
+const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01'
+const COIN = '0x00000000000000000000000000000000000000000000000000000000000000c1'
+const GAS = '0x00000000000000000000000000000000000000000000000000000000000000c2'
+const DIGEST = '11111111111111111111111111111111'
+const USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
+const SCAM = '0x778e2ae6096389cfad3e0b1c6e8080a9287f8792a08d3df1f9025251d820be93::usdt::USDT'
+
+const u64 = (n: string): Uint8Array => bcs.u64().serialize(n).toBytes()
+
+interface BookReading {
+ sold?: [string, string]
+ bought?: [string, string]
+ mid?: string
+ taker?: string
+}
+
+/** 10 SUI into 9.29 USDC with 0.02 SUI left over, and 10 USDC into 10.7 SUI with 0.03 USDC left. */
+function fakeIo(reading: BookReading = {}, prices: Record = {}) {
+ const views: Transaction[] = []
+ const built: Transaction[] = []
+ const io: SwapIo = {
+ view: async (tx) => {
+ views.push(tx)
+ const quantities = tx
+ .getData()
+ .commands.some((c) => c.MoveCall?.function === 'get_base_quantity_out_input_fee')
+ ? (reading.bought ?? ['10700000000', '30000'])
+ : (reading.sold ?? ['20000000', '9290000'])
+ return [
+ [u64(quantities[0]), u64(quantities[1]), u64('0')],
+ [u64(reading.mid ?? '931800')],
+ [u64(reading.taker ?? '200000'), u64('0'), u64('100000000000')],
+ ]
+ },
+ build: async (tx) => {
+ built.push(tx)
+ tx.setGasPrice(1000)
+ tx.setGasBudget(50_000_000)
+ tx.setGasPayment([{ objectId: GAS, version: '1', digest: DIGEST }])
+ return toBase64(await tx.build())
+ },
+ price: async (token) =>
+ token.symbol in prices ? prices[token.symbol] : token.symbol === 'SUI' ? 0.932 : 1,
+ spend: (tx) => tx.objectRef({ objectId: COIN, version: '1', digest: DIGEST }),
+ }
+ return { io, views, built }
+}
+
+const SELL: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'SUI', tokenOut: 'USDC' }
+const BUY: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' }
+
+async function preparedBytes(request: ChainActionRequest = SELL): Promise {
+ const prepared = await createDeepbookVenue(fakeIo().io).prepare(request, ME)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ return prepared.signing.transaction
+}
+
+const venue = createDeepbookVenue(fakeIo().io)
+let SOLD = ''
+
+beforeAll(async () => {
+ SOLD = await preparedBytes(SELL)
+})
+
+const check = (transaction: string, account = ME) =>
+ venue.assertSafe({ family: 'sui', transaction }, account)
+
+async function edited(base: string, edit: (tx: Transaction) => void): Promise {
+ const tx = Transaction.from(base)
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+interface JsonArg {
+ Input?: number
+ Result?: number
+ NestedResult?: [number, number]
+}
+interface JsonTx {
+ inputs: { Pure?: { bytes: string }; Object?: { SharedObject?: { objectId: string } } }[]
+ commands: {
+ MoveCall?: {
+ package: string
+ module: string
+ function: string
+ typeArguments: string[]
+ arguments: JsonArg[]
+ }
+ }[]
+}
+
+async function rewritten(base: string, edit: (data: JsonTx) => void): Promise {
+ const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx
+ edit(data)
+ return toBase64(await Transaction.from(JSON.stringify(data)).build())
+}
+
+function swapCall(data: JsonTx) {
+ const call = data.commands.find((c) => c.MoveCall?.function.startsWith('swap_exact'))?.MoveCall
+ if (call === undefined) throw new Error('no swap in this transaction')
+ return call
+}
+
+function moveCalls(transaction: string) {
+ return Transaction.from(transaction)
+ .getData()
+ .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall] : []))
+}
+
+describe('the DeepBook quote', () => {
+ it('reads what the book gives, and builds nothing', async () => {
+ const { io, views, built } = fakeIo()
+ const quoted = await createDeepbookVenue(io).quote?.(SELL, ME)
+ expect(quoted?.receive).toEqual({ amount: '9.29', symbol: 'USDC' })
+ expect(built).toHaveLength(0)
+ expect(views).toHaveLength(1)
+ const names = views[0]?.getData().commands.map((c) => c.MoveCall?.function)
+ expect(names).toEqual(['get_quote_quantity_out_input_fee', 'mid_price', 'pool_trade_params'])
+ })
+
+ it('asks the input-fee path, so no DEEP is needed to read a price', async () => {
+ const { io, views } = fakeIo()
+ await createDeepbookVenue(io).quote?.(BUY, ME)
+ const first = views[0]?.getData().commands[0]?.MoveCall
+ expect(first?.function).toBe('get_base_quantity_out_input_fee')
+ })
+
+ it('refuses a pair with no direct pool', async () => {
+ await expect(
+ venue.quote?.({ ...SELL, tokenIn: 'USDsui', tokenOut: 'USDT' }, ME)
+ ).rejects.toThrow(/no direct pool/i)
+ })
+
+ it('refuses a token outside the list', async () => {
+ await expect(venue.quote?.({ ...SELL, tokenOut: 'FDUSD' }, ME)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('refuses when the read fails, rather than guessing', async () => {
+ const { io } = fakeIo()
+ io.view = () => Promise.reject(new Error('fullnode down'))
+ const failing = createDeepbookVenue(io).quote?.(SELL, ME)
+ await expect(failing).rejects.toThrow(ChainActionRefused)
+ await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/couldn't read/i)
+ })
+
+ it('refuses an amount below the smallest order, where nothing would trade', async () => {
+ const { io } = fakeIo({ sold: ['1000000000', '0'] })
+ await expect(createDeepbookVenue(io).quote?.({ ...SELL, amount: '1' }, ME)).rejects.toThrow(
+ /can't fill/i
+ )
+ })
+
+ it('refuses when more than 1% would stay unspent', async () => {
+ const { io } = fakeIo({ sold: ['200000000', '9110000'] })
+ await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/part of this amount/i)
+ })
+
+ it('refuses a stale book whose fill is far from its own mid price', async () => {
+ const { io } = fakeIo({ sold: ['20000000', '100000'] })
+ await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses a fill worth far less than the market, even at the mid price', async () => {
+ const { io } = fakeIo({}, { SUI: 2 })
+ await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/worse than the market/)
+ })
+
+ it('refuses when no outside price can be read', async () => {
+ const { io } = fakeIo({}, { USDC: undefined })
+ await expect(createDeepbookVenue(io).quote?.(SELL, ME)).rejects.toThrow(/live price/)
+ })
+
+ it('refuses an account that is not a Sui address', async () => {
+ await expect(venue.quote?.(SELL, 'not-an-address')).rejects.toThrow(/Sui address/)
+ })
+})
+
+describe('what DeepBook prepare builds', () => {
+ it('sells base with a minimum 0.5% under the quote, and no DEEP from the wallet', async () => {
+ const transaction = await preparedBytes(SELL)
+ const data = Transaction.from(transaction).getData()
+ expect(data.sender).toBe(ME)
+ const calls = moveCalls(transaction)
+ expect(calls.map((c) => `${c.module}::${c.function}`)).toEqual([
+ 'coin::zero',
+ 'pool::swap_exact_base_for_quote',
+ 'coin::destroy_zero',
+ ])
+ const swap = calls[1]
+ expect(swap?.package).toBe(DEEPBOOK_PACKAGE)
+ const minimum = swap?.arguments[3]
+ if (minimum?.$kind !== 'Input') throw new Error('minimum is not stated')
+ const input = data.inputs[minimum.Input]
+ expect(bcs.u64().parse(fromBase64(input?.Pure?.bytes ?? ''))).toBe('9243550')
+ expect(calls[0]?.typeArguments).toEqual([DEEP_TYPE])
+ expect(() => check(transaction)).not.toThrow()
+ })
+
+ it('buys base through the other function, and passes its own check', async () => {
+ const transaction = await preparedBytes(BUY)
+ expect(moveCalls(transaction)[1]?.function).toBe('swap_exact_quote_for_base')
+ expect(() => check(transaction)).not.toThrow()
+ })
+
+ it('sends both leftovers and the output to the person', async () => {
+ const data = Transaction.from(await preparedBytes(SELL)).getData()
+ const transfer = data.commands.find((c) => c.$kind === 'TransferObjects')?.TransferObjects
+ expect(transfer?.objects).toHaveLength(2)
+ const to = transfer?.address
+ if (to?.$kind !== 'Input') throw new Error('recipient is not stated')
+ const bytes = data.inputs[to.Input]?.Pure?.bytes ?? ''
+ expect(bcs.Address.parse(fromBase64(bytes))).toBe(ME)
+ })
+
+ it('tells the person the price protection, the fee it read, the leftover and the risks', async () => {
+ const prepared = await venue.prepare(SELL, ME)
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/more than 0\.5%, the swap cancels/)
+ expect(notes).toMatch(/fee is about 0\.025%/)
+ expect(notes).toMatch(/0\.02 SUI/)
+ expect(notes).toMatch(/can upgrade DeepBook's contracts/)
+ expect(prepared.receive).toEqual({ amount: '9.29', symbol: 'USDC', approximate: true })
+ expect(prepared.pay).toEqual({ amount: '10', symbol: 'SUI' })
+ expect(prepared.venueId).toBe('deepbook')
+ expect(prepared.kind).toBe('swap')
+ })
+
+ it('says a bridged USDT depends on the bridge', async () => {
+ const { io } = fakeIo({ sold: ['1000', '9990000'], mid: '999000000', taker: '10000' })
+ const prepared = await createDeepbookVenue(io).prepare(
+ { ...SELL, tokenIn: 'USDT', tokenOut: 'USDC' },
+ ME
+ )
+ expect(prepared.notes.join(' ')).toMatch(/Sui Bridge/)
+ })
+})
+
+describe('the DeepBook safety check refuses', () => {
+ it('a different account', async () => {
+ expect(() => check(SOLD, THIEF)).toThrow(/different account/)
+ })
+
+ it('a transaction sent by someone else', async () => {
+ const tampered = await edited(SOLD, (tx) => tx.setSender(THIEF))
+ expect(() => check(tampered)).toThrow(/different account/)
+ })
+
+ it('an unknown contract', async () => {
+ const tampered = await edited(SOLD, (tx) => {
+ tx.moveCall({ target: `${FOREIGN}::drain::everything` })
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('an upgraded package id, until someone checks it', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ swapCall(data).package = FOREIGN
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('a DeepBook function this flow never needs', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ swapCall(data).function = 'place_market_order'
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('an extra argument', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const call = swapCall(data)
+ call.arguments.push(call.arguments[3] as JsonArg)
+ })
+ expect(() => check(tampered)).toThrow(/does not match/)
+ })
+
+ it('payment to someone else', async () => {
+ const tampered = await edited(SOLD, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.transferObjects([coin as never], THIEF)
+ })
+ expect(() => check(tampered)).toThrow(/another address/)
+ })
+
+ it('a framework transfer that hides the recipient', async () => {
+ const tampered = await edited(SOLD, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target: '0x2::transfer::public_transfer',
+ typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'],
+ arguments: [coin as never, tx.pure.address(THIEF)],
+ })
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('no minimum, which is what an unlimited approval would be here', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = swapCall(data).arguments[3]?.Input
+ const input = at === undefined ? undefined : data.inputs[at]
+ if (input?.Pure === undefined) throw new Error('no minimum')
+ input.Pure.bytes = toBase64(u64('0'))
+ })
+ expect(() => check(tampered)).toThrow(/minimum/)
+ })
+
+ it('a minimum computed elsewhere in the transaction', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const call = swapCall(data)
+ call.arguments[3] = { NestedResult: [0, 0] }
+ })
+ expect(() => check(tampered)).toThrow(/minimum/)
+ })
+
+ it('a token outside the pool', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ swapCall(data).typeArguments[1] = SCAM
+ })
+ expect(() => check(tampered)).toThrow(/pool/)
+ })
+
+ it('a pool that is not pinned', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = swapCall(data).arguments[0]?.Input
+ const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject
+ if (pool === undefined) throw new Error('no pool')
+ pool.objectId = FOREIGN
+ })
+ expect(() => check(tampered)).toThrow(/pool/)
+ })
+
+ it("one pool's id with another pair's types", async () => {
+ const other = DEEPBOOK_POOLS.find((p) => p.base === 'USDT')
+ if (other === undefined) throw new Error('no USDT pool')
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = swapCall(data).arguments[0]?.Input
+ const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject
+ if (pool === undefined) throw new Error('no pool')
+ pool.objectId = other.id
+ })
+ expect(() => check(tampered)).toThrow(/pool/)
+ })
+
+ it('DEEP drawn from the wallet', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const call = swapCall(data)
+ call.arguments[2] = call.arguments[1] as JsonArg
+ })
+ expect(() => check(tampered)).toThrow(/DEEP/)
+ })
+
+ it('a zero coin of anything but DEEP', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const zero = data.commands.find((c) => c.MoveCall?.function === 'zero')?.MoveCall
+ if (zero === undefined) throw new Error('no zero coin')
+ zero.typeArguments = [USDC]
+ })
+ expect(() => check(tampered)).toThrow(/DEEP/)
+ })
+
+ it('a clock that is not the clock', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = swapCall(data).arguments[4]?.Input
+ const clock = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject
+ if (clock === undefined) throw new Error('no clock')
+ clock.objectId = FOREIGN
+ })
+ expect(() => check(tampered)).toThrow(/does not match/)
+ })
+
+ it('two swaps in one transaction', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const swap = data.commands.find((c) => c.MoveCall?.function.startsWith('swap_exact'))
+ if (swap === undefined) throw new Error('no swap')
+ data.commands.push(JSON.parse(JSON.stringify(swap)) as JsonTx['commands'][number])
+ })
+ expect(() => check(tampered)).toThrow(/one swap/)
+ })
+
+ it('a transaction with no swap at all', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ data.commands = data.commands.filter((c) => !c.MoveCall?.function.startsWith('swap_exact'))
+ })
+ expect(() => check(tampered)).toThrow(/one swap/)
+ })
+
+ it('a payload for another chain', () => {
+ expect(() => venue.assertSafe({ family: 'solana', transaction: SOLD }, ME)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('bytes that are not a transaction', () => {
+ expect(() => check('not base64 at all!')).toThrow(ChainActionRefused)
+ })
+})
+
+describe('the DeepBook pins and listing', () => {
+ it('pins exactly the pools for tokens in the table', () => {
+ expect(DEEPBOOK_POOLS.map((p) => `${p.base}/${p.quote}`).sort()).toEqual([
+ 'SUI/USDC',
+ 'SUI/USDsui',
+ 'USDT/USDC',
+ 'USDsui/USDC',
+ ])
+ })
+
+ it('lists as executing on Sui, and says limit orders are not connected', () => {
+ expect(deepbookListing.id).toBe('deepbook')
+ expect(deepbookListing.family).toBe('sui')
+ expect(deepbookListing.integration).toBe('executes')
+ expect(deepbookListing.capability).toMatch(/limit orders/i)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/erc4626-refusals.ts b/apps/dapp/lib/__tests__/erc4626-refusals.ts
new file mode 100644
index 00000000..971c970f
--- /dev/null
+++ b/apps/dapp/lib/__tests__/erc4626-refusals.ts
@@ -0,0 +1,214 @@
+import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import type { ReviewedVault } from '../chains/evm/erc4626'
+import { ChainActionRefused, type ChainVenue, type SigningPayload } from '../chains/types'
+
+export const ACCOUNT = '0x1111111111111111111111111111111111111111'
+export const STRANGER = '0x2222222222222222222222222222222222222222'
+const OTHER_TOKEN = '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7'
+const FIVE = BigInt(5_000_000)
+
+const ABI = parseAbi([
+ 'function deposit(uint256 assets, address receiver) returns (uint256)',
+ 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)',
+ 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
+ 'function mint(uint256 shares, address receiver) returns (uint256)',
+ 'function transfer(address to, uint256 amount) returns (bool)',
+ 'function approve(address spender, uint256 amount) returns (bool)',
+])
+
+export function depositData(receiver: string = ACCOUNT, assets: bigint = FIVE): string {
+ return encodeFunctionData({
+ abi: ABI,
+ functionName: 'deposit',
+ args: [assets, receiver as Address],
+ })
+}
+
+export function withdrawData(receiver: string = ACCOUNT, owner: string = ACCOUNT): string {
+ return encodeFunctionData({
+ abi: ABI,
+ functionName: 'withdraw',
+ args: [FIVE, receiver as Address, owner as Address],
+ })
+}
+
+export function redeemData(
+ shares: bigint = FIVE,
+ receiver: string = ACCOUNT,
+ owner: string = ACCOUNT
+): string {
+ return encodeFunctionData({
+ abi: ABI,
+ functionName: 'redeem',
+ args: [shares, receiver as Address, owner as Address],
+ })
+}
+
+export function approveData(spender: string, units: bigint = FIVE): string {
+ return encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spender as Address, units],
+ })
+}
+
+/** The same refusals for every vault on this path, proved on each venue's own policy. */
+export function describeVaultRefusals(
+ venue: ChainVenue,
+ vault: ReviewedVault,
+ chainId: number,
+ offList: readonly string[]
+): void {
+ const payload = (overrides: Partial> = {}) => ({
+ family: 'evm' as const,
+ chainId,
+ to: vault.address,
+ data: depositData(),
+ value: '0',
+ ...overrides,
+ })
+ const approval = (to: string, data: string) => payload({ approval: { to, data } })
+
+ describe(`${venue.id} assertSafe`, () => {
+ it('passes a deposit and a withdrawal for the person', () => {
+ expect(() => venue.assertSafe(payload(), ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(payload({ data: withdrawData() }), ACCOUNT)).not.toThrow()
+ })
+
+ it('passes a redeem of the shares, to and from the person', () => {
+ expect(() => venue.assertSafe(payload({ data: redeemData() }), ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(payload({ data: redeemData() }), STRANGER)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('passes a deposit with an exact approval of the vault', () => {
+ const signing = approval(vault.asset.id, approveData(vault.address))
+ expect(() => venue.assertSafe(signing, ACCOUNT)).not.toThrow()
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', payload({ chainId: chainId + 1 })],
+ ['native value', payload({ value: '1' })],
+ ['a deposit crediting someone else', payload({ data: depositData(STRANGER) })],
+ ['a withdrawal paying someone else', payload({ data: withdrawData(STRANGER) })],
+ ['a withdrawal from someone else', payload({ data: withdrawData(ACCOUNT, STRANGER) })],
+ ['a deposit of nothing', payload({ data: depositData(ACCOUNT, BigInt(0)) })],
+ ['extra calldata', payload({ data: `${depositData()}00` })],
+ ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })],
+ ['a redeem paying someone else', payload({ data: redeemData(FIVE, STRANGER) })],
+ ['a redeem from someone else', payload({ data: redeemData(FIVE, ACCOUNT, STRANGER) })],
+ ['a redeem of nothing', payload({ data: redeemData(BigInt(0)) })],
+ ['a redeem naming no amount', payload({ data: redeemData(maxUint256) })],
+ ['a redeem with extra data', payload({ data: `${redeemData()}00` })],
+ [
+ 'an approval beside a redeem',
+ payload({
+ data: redeemData(),
+ approval: { to: vault.asset.id, data: approveData(vault.address) },
+ }),
+ ],
+ [
+ 'mint, which is not allowed',
+ payload({
+ data: encodeFunctionData({
+ abi: ABI,
+ functionName: 'mint',
+ args: [FIVE, ACCOUNT as Address],
+ }),
+ }),
+ ],
+ [
+ 'moving the shares',
+ payload({
+ data: encodeFunctionData({
+ abi: ABI,
+ functionName: 'transfer',
+ args: [STRANGER as Address, FIVE],
+ }),
+ }),
+ ],
+ [
+ 'approving someone to take the shares',
+ payload({
+ data: encodeFunctionData({
+ abi: ABI,
+ functionName: 'approve',
+ args: [STRANGER as Address, FIVE],
+ }),
+ }),
+ ],
+ ['the token itself as the target', payload({ to: vault.asset.id })],
+ ['an unlimited approval', approval(vault.asset.id, approveData(vault.address, maxUint256))],
+ ['an approval of a stranger', approval(vault.asset.id, approveData(STRANGER))],
+ ['an approval of the wrong token', approval(OTHER_TOKEN, approveData(vault.address))],
+ [
+ 'an approval for another amount',
+ approval(vault.asset.id, approveData(vault.address, BigInt(6))),
+ ],
+ ['an approval with extra data', approval(vault.asset.id, `${approveData(vault.address)}00`)],
+ [
+ 'a first step that pays someone',
+ approval(
+ vault.asset.id,
+ encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [STRANGER as Address, FIVE],
+ })
+ ),
+ ],
+ [
+ 'an approval beside a withdrawal',
+ payload({
+ data: withdrawData(),
+ approval: { to: vault.asset.id, data: approveData(vault.address) },
+ }),
+ ],
+ ])('refuses %s', (_label, signing) => {
+ expect(() => venue.assertSafe(signing, ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a transaction built for another account', () => {
+ expect(() => venue.assertSafe(payload(), STRANGER)).toThrow(ChainActionRefused)
+ expect(() => venue.assertSafe(payload({ data: withdrawData() }), STRANGER)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it.each(offList.map((address) => [address]))('refuses the vault off the list at %s', (to) => {
+ expect(() => venue.assertSafe(payload({ to }), ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a token this vault does not take', async () => {
+ const request = { kind: 'supply' as const, amount: '5', tokenIn: 'USDT' }
+ await expect(venue.prepare(request, ACCOUNT)).rejects.toThrow(/takes USDC/)
+ await expect(venue.quote?.(request, ACCOUNT)).rejects.toThrow(/takes USDC/)
+ })
+
+ it('refuses an account that is not an address', async () => {
+ const request = { kind: 'supply' as const, amount: '5', tokenIn: 'USDC' }
+ await expect(venue.prepare(request, 'not-an-address')).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses a kind it does not run', async () => {
+ const request = { kind: 'borrow' as const, amount: '5', tokenIn: 'USDC' }
+ await expect(venue.prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('can only supply after a swap, and only its own token', async () => {
+ await expect(
+ venue.assertCanFollow?.({ kind: 'supply', tokenIn: 'USDC' }, ACCOUNT)
+ ).resolves.toBeUndefined()
+ await expect(
+ venue.assertCanFollow?.({ kind: 'supply', tokenIn: 'USDT' }, ACCOUNT)
+ ).rejects.toThrow(ChainActionRefused)
+ await expect(
+ venue.assertCanFollow?.({ kind: 'withdraw', tokenIn: 'USDC' }, ACCOUNT)
+ ).rejects.toThrow(ChainActionRefused)
+ })
+ })
+}
diff --git a/apps/dapp/lib/__tests__/euler.live.test.ts b/apps/dapp/lib/__tests__/euler.live.test.ts
new file mode 100644
index 00000000..4220160a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/euler.live.test.ts
@@ -0,0 +1,192 @@
+import {
+ encodeAbiParameters,
+ encodeFunctionData,
+ http,
+ keccak256,
+ pad,
+ parseAbi,
+ toHex,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { evmReader } from '../chains/evm/client'
+import {
+ createEulerAvalancheVenue,
+ createEulerMonadVenue,
+ EULER_AVALANCHE_COLLATERAL,
+ EULER_AVALANCHE_VAULT,
+ EULER_FACTORY_AVALANCHE,
+ EULER_FACTORY_MONAD,
+ EULER_MONAD_COLLATERAL,
+ EULER_MONAD_VAULT,
+} from '../chains/evm/euler'
+import type { ReviewedVault } from '../chains/evm/erc4626'
+import { sameAddress } from '../chains/evm/submit'
+import type { ChainVenue } from '../chains/types'
+
+/**
+ * Whether what's pinned still holds, read-only. Nothing here signs or sends: a deposit is tried
+ * with `eth_call` and overridden balances, and a withdrawal from a vault that holds these shares,
+ * whose key nobody has. If a holder leaves, swap it for another.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const NOBODY = '0x000000000000000000000000000000000000dEaD'
+const SLOW_MS = 90_000
+/** Where Circle's USDC keeps balances and allowances, found by probing with an override. */
+const USDC_BALANCE_SLOT = BigInt(9)
+const USDC_ALLOWANCE_SLOT = BigInt(10)
+/** Stream's and Elixir's tokens, which left other Euler vaults on Avalanche stuck. */
+const STREAM_TOKENS = ['XUSD', 'DEUSD', 'SDEUSD']
+
+const ABI = parseAbi([
+ 'function asset() view returns (address)',
+ 'function symbol() view returns (string)',
+ 'function decimals() view returns (uint8)',
+ 'function governorAdmin() view returns (address)',
+ 'function isProxy(address) view returns (bool)',
+ 'function LTVList() view returns (address[])',
+ 'function cash() view returns (uint256)',
+ 'function totalAssets() view returns (uint256)',
+ 'function hookConfig() view returns (address, uint32)',
+ 'function deposit(uint256, address) returns (uint256)',
+])
+
+const slotOf = (key: string, slot: bigint): Hex =>
+ keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key as Address, slot]))
+const allowanceSlot = (owner: string, spender: string): Hex =>
+ keccak256(
+ encodeAbiParameters(
+ [{ type: 'address' }, { type: 'bytes32' }],
+ [spender as Address, slotOf(owner, USDC_ALLOWANCE_SLOT)]
+ )
+ )
+
+interface Case {
+ label: string
+ chain: 'monad' | 'avalanche'
+ rpc: string
+ vault: ReviewedVault
+ factory: string
+ governor: string
+ collateral: readonly string[]
+ holder: string
+ create: (reader: ReturnType) => ChainVenue
+}
+
+const CASES: Case[] = [
+ {
+ label: 'Monad',
+ chain: 'monad',
+ rpc: process.env['MONAD_RPC_URL']?.trim() || 'https://rpc.monad.xyz',
+ vault: EULER_MONAD_VAULT,
+ factory: EULER_FACTORY_MONAD,
+ governor: '0xb3CF59A5f12cA319861376C5e63Eef4790a42B44',
+ collateral: EULER_MONAD_COLLATERAL,
+ holder: '0xE1BcA19baA63894D374578320551633320436523',
+ create: (reader) => createEulerMonadVenue({ reader }),
+ },
+ {
+ label: 'Avalanche',
+ chain: 'avalanche',
+ rpc: process.env['AVALANCHE_RPC_URL']?.trim() || 'https://avalanche-c-chain-rpc.publicnode.com',
+ vault: EULER_AVALANCHE_VAULT,
+ factory: EULER_FACTORY_AVALANCHE,
+ governor: '0x23E6aecB76675462Ad8f2B31eC7C492060c2fAEF',
+ collateral: EULER_AVALANCHE_COLLATERAL,
+ holder: '0x4aF3aBE954259fb70b97C57EBD7Ac1eb822028Ef',
+ create: (reader) => createEulerAvalancheVenue({ reader }),
+ },
+]
+
+describe.skipIf(SKIP).each(CASES)('Euler on $label, live', (item) => {
+ const reader = evmReader(item.chain, http(item.rpc, { timeout: 30_000, retryCount: 2 }))
+ const vault = item.vault.address as Address
+ const read = (address: string, functionName: string, args: unknown[] = []) =>
+ reader.readContract({
+ address: address as Address,
+ abi: ABI,
+ functionName,
+ args,
+ } as never) as Promise
+
+ it('finds code at the factory and the vault', { timeout: SLOW_MS }, async () => {
+ for (const address of [item.factory, vault]) {
+ const code = await reader.getCode({ address: address as Address })
+ expect(code?.length ?? 0).toBeGreaterThan(2)
+ }
+ })
+
+ it('is still the reviewed vault', { timeout: SLOW_MS }, async () => {
+ expect(await read(item.factory, 'isProxy', [vault])).toBe(true)
+ expect(sameAddress((await read(vault, 'asset')) as string, item.vault.asset.id)).toBe(true)
+ expect(await read(vault, 'symbol')).toBe(item.vault.shareSymbol)
+ expect(await read(vault, 'decimals')).toBe(item.vault.shareDecimals)
+ expect(sameAddress((await read(vault, 'governorAdmin')) as string, item.governor)).toBe(true)
+ })
+
+ it('has no hook that could block a deposit or a withdrawal', { timeout: SLOW_MS }, async () => {
+ const [hook, ops] = (await read(vault, 'hookConfig')) as readonly [string, number]
+ expect(sameAddress(hook, `0x${'0'.repeat(40)}`)).toBe(true)
+ expect(ops).toBe(0)
+ })
+
+ it('takes only reviewed collateral, with no Stream token', { timeout: SLOW_MS }, async () => {
+ const listed = (await read(vault, 'LTVList')) as readonly string[]
+ expect([...listed].sort()).toEqual([...item.collateral].sort())
+ for (const entry of listed) {
+ const asset = (await read(entry, 'asset')) as string
+ const symbol = ((await read(asset, 'symbol')) as string).toUpperCase()
+ expect(STREAM_TOKENS).not.toContain(symbol)
+ }
+ })
+
+ it('still has money free', { timeout: SLOW_MS }, async () => {
+ const [cash, total] = await Promise.all([read(vault, 'cash'), read(vault, 'totalAssets')])
+ expect((cash as bigint) * BigInt(100)).toBeGreaterThan((total as bigint) * BigInt(5))
+ })
+
+ it('prepares a deposit with a believable rate', { timeout: SLOW_MS }, async () => {
+ const venue = item.create(reader)
+ // The vault's own cash stands in for a person with USDC. Nothing is signed.
+ const prepared = await venue.prepare({ kind: 'supply', amount: '5', tokenIn: 'USDC' }, vault)
+ expect(() => venue.assertSafe(prepared.signing, vault)).not.toThrow()
+ expect(prepared.apy?.basePercent).toBeGreaterThan(0)
+ expect(prepared.apy?.basePercent).toBeLessThan(50)
+ })
+
+ it(
+ 'accepts the deposit this code builds, with funds overridden',
+ { timeout: SLOW_MS },
+ async () => {
+ const units = BigInt(5_000_000)
+ await expect(
+ reader.call({
+ account: NOBODY,
+ to: vault,
+ data: encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [units, NOBODY] }),
+ stateOverride: [
+ {
+ address: item.vault.asset.id as Address,
+ stateDiff: [
+ { slot: slotOf(NOBODY, USDC_BALANCE_SLOT), value: pad(toHex(units)) },
+ { slot: allowanceSlot(NOBODY, vault), value: pad(toHex(units)) },
+ ],
+ },
+ ],
+ })
+ ).resolves.toBeDefined()
+ }
+ )
+
+ it('prepares and tries a withdrawal for a real holder', { timeout: SLOW_MS }, async () => {
+ const venue = item.create(reader)
+ const prepared = await venue.prepare(
+ { kind: 'withdraw', amount: '5', tokenIn: 'USDC' },
+ item.holder
+ )
+ expect(() => venue.assertSafe(prepared.signing, item.holder)).not.toThrow()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/euler.test.ts b/apps/dapp/lib/__tests__/euler.test.ts
new file mode 100644
index 00000000..1582970f
--- /dev/null
+++ b/apps/dapp/lib/__tests__/euler.test.ts
@@ -0,0 +1,245 @@
+import { zeroAddress } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import type { EvmReaderClient } from '../chains/evm/call'
+import {
+ createEulerAvalancheVenue,
+ createEulerMonadVenue,
+ EULER_AVALANCHE_COLLATERAL,
+ EULER_AVALANCHE_VAULT,
+ EULER_MONAD_COLLATERAL,
+ EULER_MONAD_VAULT,
+} from '../chains/evm/euler'
+import { eulerAvalancheListing, eulerMonadListing } from '../chains/evm/euler-listing'
+import { AVALANCHE_TOKENS, MONAD_TOKENS } from '../chains/evm/tokens'
+import type { ChainActionRequest } from '../chains/types'
+import { ACCOUNT, describeVaultRefusals, redeemData, STRANGER } from './erc4626-refusals'
+
+const SUPPLY: ChainActionRequest = { kind: 'supply', amount: '100', tokenIn: 'USDC' }
+const WITHDRAW: ChainActionRequest = { kind: 'withdraw', amount: '100', tokenIn: 'USDC' }
+
+const units = (usdc: number) => BigInt(usdc) * BigInt(1_000_000)
+const RATE = BigInt('2500000000000000000')
+
+interface Scene {
+ cash?: bigint
+ total?: bigint
+ borrows?: bigint
+ held?: bigint
+ shares?: bigint
+ room?: bigint
+ collateral?: readonly string[]
+ rateFails?: boolean
+ cashFails?: boolean
+ hookTarget?: string
+ hookedOps?: number
+ simulationFails?: boolean
+}
+
+function fakeReader(scene: Scene, collateral: readonly string[]) {
+ const reader = {
+ call: async () => {
+ if (scene.simulationFails) throw new Error('reverted')
+ return {}
+ },
+ readContract: async ({
+ address,
+ functionName,
+ args,
+ }: {
+ address: string
+ functionName: string
+ args?: readonly unknown[]
+ }) => {
+ switch (functionName) {
+ case 'balanceOf':
+ return address === EULER_MONAD_VAULT.address || address === EULER_AVALANCHE_VAULT.address
+ ? (scene.shares ?? BigInt(0))
+ : (scene.held ?? units(1_000))
+ case 'allowance':
+ return BigInt(0)
+ case 'totalAssets':
+ return scene.total ?? units(1_000_000)
+ case 'totalBorrows':
+ return scene.borrows ?? units(900_000)
+ case 'cash':
+ if (scene.cashFails) throw new Error('no reading')
+ return scene.cash ?? units(100_000)
+ case 'interestRate':
+ if (scene.rateFails) throw new Error('no reading')
+ return RATE
+ case 'interestFee':
+ return 1000
+ case 'LTVList':
+ return scene.collateral ?? collateral
+ case 'maxDeposit':
+ return scene.room ?? units(10_000_000)
+ case 'previewDeposit':
+ return (args?.[0] as bigint) - BigInt(1)
+ case 'convertToAssets':
+ return args?.[0] as bigint
+ case 'hookConfig':
+ return [scene.hookTarget ?? zeroAddress, scene.hookedOps ?? 0]
+ default:
+ throw new Error(`Unexpected read: ${functionName}`)
+ }
+ },
+ }
+ return reader as unknown as EvmReaderClient
+}
+
+const monadWith = (scene: Scene = {}) =>
+ createEulerMonadVenue({ reader: fakeReader(scene, EULER_MONAD_COLLATERAL) })
+const avalancheWith = (scene: Scene = {}) =>
+ createEulerAvalancheVenue({ reader: fakeReader(scene, EULER_AVALANCHE_COLLATERAL) })
+
+describeVaultRefusals(monadWith(), EULER_MONAD_VAULT, 143, [
+ // K3's yzPrime and PT vaults: structured collateral, reviewed and left out.
+ '0xa3B64e2674463c98CbD21807055D8C1E008b6e79',
+ '0x06047833d9aAC144f7F4e9B8234D2c7DDA1a8BAf',
+ // The vault for Avalanche, which isn't one on this chain.
+ EULER_AVALANCHE_VAULT.address,
+])
+
+describeVaultRefusals(avalancheWith(), EULER_AVALANCHE_VAULT, 43114, [
+ // Re7 USDC and K3 USDT Earn: deprecated for exposure to Stream in euler-labels.
+ '0xEAF77Df5D03306bCA4EE8b58B6821E6acA76309d',
+ '0xE1A62FDcC6666847d5EA752634E45e134B2F824B',
+ // Re7's eUSDC-2, which lends against deUSD and sdeUSD.
+ '0x39dE0f00189306062D79eDEC6DcA5bb6bFd108f9',
+ // 9Summits' own Earn vault, whose queue includes that one.
+ '0x4aF3aBE954259fb70b97C57EBD7Ac1eb822028Ef',
+])
+
+describe('Euler vaults', () => {
+ it('pins the USDC in each chain table', () => {
+ expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')).toEqual(EULER_MONAD_VAULT.asset)
+ expect(AVALANCHE_TOKENS.find((t) => t.symbol === 'USDC')).toEqual(EULER_AVALANCHE_VAULT.asset)
+ })
+
+ it('lists one venue per chain', () => {
+ expect(eulerMonadListing).toMatchObject({
+ id: 'euler-monad',
+ chains: ['Monad'],
+ integration: 'executes',
+ })
+ expect(eulerAvalancheListing).toMatchObject({
+ id: 'euler-avalanche',
+ chains: ['Avalanche'],
+ integration: 'executes',
+ })
+ })
+
+ it('names the curator and the collateral on each card', async () => {
+ const monad = await monadWith().prepare(SUPPLY, ACCOUNT)
+ expect(monad.summary).toContain('Clearstar')
+ expect(monad.notes.join(' ')).toMatch(/only to borrowers holding FXRP/)
+ const avalanche = await avalancheWith().prepare(SUPPLY, ACCOUNT)
+ expect(avalanche.summary).toContain('9 Summits')
+ expect(avalanche.notes.join(' ')).toMatch(/savUSD/)
+ expect(avalanche.notes.join(' ')).toMatch(/\$93 million/)
+ })
+
+ it('works the supply rate out from the borrow rate, what is lent and the fee', async () => {
+ const venue = monadWith()
+ const { apy } = await venue.prepare(SUPPLY, ACCOUNT)
+ const perSecond = 2.5e-9 * 0.9 * 0.9
+ const expected = (Math.pow(1 + perSecond, 365.2425 * 86_400) - 1) * 100
+ expect(apy?.basePercent).toBeCloseTo(expected, 6)
+ expect(apy?.rewardPercent).toBeUndefined()
+ await expect(venue.quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({ apyPercent: apy?.basePercent })
+ })
+
+ it('shows no rate when it cannot be read, or nothing is borrowed', async () => {
+ expect(await monadWith({ rateFails: true }).prepare(SUPPLY, ACCOUNT)).not.toHaveProperty('apy')
+ expect(await monadWith({ borrows: BigInt(0) }).prepare(SUPPLY, ACCOUNT)).not.toHaveProperty(
+ 'apy'
+ )
+ await expect(monadWith({ rateFails: true }).quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({})
+ })
+
+ it('refuses a vault taking collateral that was not reviewed, as a Stream token would be', async () => {
+ const collateral = [...EULER_AVALANCHE_COLLATERAL, '0xFECebF56f150A68dDd197c3a6101da35C46693Ac']
+ await expect(avalancheWith({ collateral }).prepare(SUPPLY, ACCOUNT)).rejects.toThrow(
+ /new collateral/
+ )
+ await expect(avalancheWith({ collateral }).quote?.(SUPPLY, ACCOUNT)).rejects.toThrow(
+ /new collateral/
+ )
+ })
+
+ it('refuses a stuck vault, the way Stream left others on Avalanche', async () => {
+ const venue = avalancheWith({ cash: units(1_000), total: units(1_000_000) })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/won't add to it/)
+ await expect(venue.quote?.(SUPPLY, ACCOUNT)).rejects.toThrow(/won't add to it/)
+ })
+
+ it('refuses more than the vault has room for', async () => {
+ await expect(monadWith({ room: units(10) }).prepare(SUPPLY, ACCOUNT)).rejects.toThrow(
+ /room for about 10 more USDC/
+ )
+ })
+
+ it('refuses when the vault cannot be read', async () => {
+ await expect(monadWith({ cashFails: true }).prepare(SUPPLY, ACCOUNT)).rejects.toThrow(
+ /couldn't read/
+ )
+ })
+
+ it('builds a withdrawal that stays in the person’s hands', async () => {
+ const venue = avalancheWith({ shares: units(500) })
+ const prepared = await venue.prepare(WITHDRAW, ACCOUNT)
+ expect(prepared.signing).toMatchObject({ family: 'evm', chainId: 43114 })
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('says plainly when the vault cannot pay out that much now', async () => {
+ const venue = avalancheWith({ shares: units(500), cash: units(40) })
+ await expect(venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(/can pay out about 40 USDC/)
+ })
+
+ it('leaves a way out when the cash cannot be read', async () => {
+ const venue = monadWith({ shares: units(500), cashFails: true })
+ await expect(venue.prepare(WITHDRAW, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' })
+ })
+})
+
+describe('Euler exits', () => {
+ const WHOLE = { ...WITHDRAW, wholePosition: true }
+
+ it('shows the position in the asset, and nothing when there is none', async () => {
+ await expect(monadWith({ shares: units(500) }).positions?.(ACCOUNT)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '500' },
+ ])
+ await expect(monadWith().positions?.(ACCOUNT)).resolves.toEqual([])
+ })
+
+ it('redeems every share for all of it, not max-uint', async () => {
+ const venue = monadWith({ shares: units(500) })
+ const prepared = await venue.prepare(WHOLE, ACCOUNT)
+ expect(prepared.signing).toMatchObject({ data: redeemData(units(500)) })
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('says plainly when the vault can not pay all of it now', async () => {
+ const venue = avalancheWith({ shares: units(500), cash: units(40) })
+ await expect(venue.prepare(WHOLE, ACCOUNT)).rejects.toThrow(/can pay out about 40 USDC/)
+ })
+
+ it('says so when the curator has switched off withdrawals', async () => {
+ const venue = monadWith({ shares: units(500), hookedOps: 1 << 3 })
+ await expect(venue.prepare(WHOLE, ACCOUNT)).rejects.toThrow(/switched off withdrawals/)
+ })
+
+ it('leaves a hook contract to the chain, and what a deposit alone is hooked for', async () => {
+ const hooked = monadWith({ shares: units(500), hookedOps: 1 << 3, hookTarget: STRANGER })
+ await expect(hooked.prepare(WHOLE, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' })
+ const deposits = monadWith({ shares: units(500), hookedOps: 1 })
+ await expect(deposits.prepare(WHOLE, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' })
+ })
+
+ it('points at a loan when the chain turns the exit down', async () => {
+ const venue = monadWith({ shares: units(500), simulationFails: true })
+ await expect(venue.prepare(WHOLE, ACCOUNT)).rejects.toThrow(/borrowed against it/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/evm-call.test.ts b/apps/dapp/lib/__tests__/evm-call.test.ts
new file mode 100644
index 00000000..40621294
--- /dev/null
+++ b/apps/dapp/lib/__tests__/evm-call.test.ts
@@ -0,0 +1,251 @@
+import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ approvalNote,
+ assertEvmCallSafe,
+ buildEvmCall,
+ confirmEvmCall,
+ type EvmCallPolicy,
+ type EvmReaderClient,
+} from '../chains/evm/call'
+import type { EvmClient, EvmReceipt } from '../chains/evm/client'
+import { sameAddress } from '../chains/evm/submit'
+import { ChainActionRefused, type SigningPayload } from '../chains/types'
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111'
+const STRANGER = '0x2222222222222222222222222222222222222222'
+const CONTRACT = '0x3333333333333333333333333333333333333333'
+const OTHER_CONTRACT = '0x4444444444444444444444444444444444444444'
+const TOKEN = '0x5555555555555555555555555555555555555555'
+const AVALANCHE_ID = 43114
+const HASH = `0x${'a'.repeat(64)}`
+
+const ABI = parseAbi([
+ 'function put(address token, uint256 amount, address onBehalfOf)',
+ 'function sweep(address to)',
+])
+
+const POLICY: EvmCallPolicy = {
+ venueName: 'Test',
+ chain: 'avalanche',
+ contracts: {
+ [CONTRACT]: {
+ abi: ABI,
+ functions: ['put'],
+ check: (call, { account, approval }) => {
+ const [, amount, onBehalfOf] = call.args as [string, bigint, string]
+ if (!sameAddress(onBehalfOf, account)) {
+ throw new ChainActionRefused('This credits someone else.')
+ }
+ if (approval !== undefined && approval.units !== amount) {
+ throw new ChainActionRefused('The approval does not match.')
+ }
+ },
+ },
+ },
+ spenders: [CONTRACT],
+ approvable: [TOKEN],
+}
+
+function put(onBehalfOf: string = ACCOUNT, amount = BigInt(5)): string {
+ return encodeFunctionData({
+ abi: ABI,
+ functionName: 'put',
+ args: [TOKEN as Address, amount, onBehalfOf as Address],
+ })
+}
+
+function approve(spender: string = CONTRACT, units = BigInt(5)): string {
+ return encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spender as Address, units],
+ })
+}
+
+function payload(overrides: Partial> = {}) {
+ return {
+ family: 'evm' as const,
+ chainId: AVALANCHE_ID,
+ to: CONTRACT,
+ data: put(),
+ value: '0',
+ ...overrides,
+ }
+}
+
+describe('assertEvmCallSafe', () => {
+ it('passes the call the venue is confirmed to make', () => {
+ expect(() => assertEvmCallSafe(payload(), ACCOUNT, POLICY)).not.toThrow()
+ })
+
+ it('passes an exact approval to a confirmed spender', () => {
+ const signing = payload({ approval: { to: TOKEN, data: approve() } })
+ expect(() => assertEvmCallSafe(signing, ACCOUNT, POLICY)).not.toThrow()
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', payload({ chainId: 143 })],
+ ['an unknown contract', payload({ to: OTHER_CONTRACT })],
+ [
+ 'a function it may not call',
+ payload({
+ data: encodeFunctionData({ abi: ABI, functionName: 'sweep', args: [STRANGER as Address] }),
+ }),
+ ],
+ ['calldata that decodes as nothing', payload({ data: '0xdeadbeef' })],
+ ['anything after the arguments', payload({ data: `${put()}00` })],
+ ['credit to someone else', payload({ data: put(STRANGER) })],
+ ['native value it may not take', payload({ value: '1' })],
+ ['a value that is not digits', payload({ value: '0x1' })],
+ [
+ 'an unlimited approval',
+ payload({ approval: { to: TOKEN, data: approve(CONTRACT, maxUint256) } }),
+ ],
+ ['an approval to a stranger', payload({ approval: { to: TOKEN, data: approve(STRANGER) } })],
+ [
+ 'an approval of an unlisted token',
+ payload({ approval: { to: OTHER_CONTRACT, data: approve() } }),
+ ],
+ [
+ 'an approval with trailing data',
+ payload({ approval: { to: TOKEN, data: `${approve()}00` } }),
+ ],
+ [
+ 'a first step that is a transfer',
+ payload({
+ approval: {
+ to: TOKEN,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [STRANGER as Address, BigInt(5)],
+ }),
+ },
+ }),
+ ],
+ [
+ 'an approval larger than the call',
+ payload({ approval: { to: TOKEN, data: approve(CONTRACT, BigInt(6)) } }),
+ ],
+ ])('refuses %s', (_label, signing) => {
+ expect(() => assertEvmCallSafe(signing, ACCOUNT, POLICY)).toThrow(ChainActionRefused)
+ })
+
+ it('lets value through only where the venue takes the native coin', () => {
+ const native = { ...POLICY, mayPayNative: true }
+ expect(() => assertEvmCallSafe(payload({ value: '1' }), ACCOUNT, native)).not.toThrow()
+ })
+})
+
+function fakeReader(allowance: bigint, callFails = false): EvmReaderClient {
+ return {
+ readContract: async () => allowance,
+ call: async () => {
+ if (callFails) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ } as unknown as EvmReaderClient
+}
+
+const PLAN = {
+ chain: 'avalanche' as const,
+ account: ACCOUNT,
+ to: CONTRACT,
+ data: put(),
+ pull: { token: TOKEN, spender: CONTRACT, units: BigInt(5) },
+}
+
+describe('buildEvmCall', () => {
+ it('asks for an exact approval only when the allowance falls short', async () => {
+ const { signing, needsApproval } = await buildEvmCall(PLAN, fakeReader(BigInt(4)))
+ expect(needsApproval).toBe(true)
+ expect(signing).toMatchObject({ approval: { to: TOKEN, data: approve() } })
+ })
+
+ it('leaves the approval off when one is in place', async () => {
+ const { signing, needsApproval } = await buildEvmCall(PLAN, fakeReader(BigInt(5)))
+ expect(needsApproval).toBe(false)
+ expect(signing).not.toHaveProperty('approval')
+ })
+
+ it('refuses a call that would fail, rather than costing the gas', async () => {
+ await expect(buildEvmCall(PLAN, fakeReader(BigInt(5), true))).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it("doesn't try a call whose approval hasn't landed, which would fail for that alone", async () => {
+ await expect(buildEvmCall(PLAN, fakeReader(BigInt(0), true))).resolves.toMatchObject({
+ needsApproval: true,
+ })
+ })
+
+ it('says the wallet will ask twice, for how much', () => {
+ expect(approvalNote('Test', '5', 'USDC')).toContain('exactly 5 USDC')
+ })
+})
+
+function fakeClient(
+ receipt: Partial | Error,
+ sent: { input: string; value: bigint } | Error = { input: put(), value: BigInt(0) }
+): EvmClient {
+ return {
+ tokenBalance: async () => BigInt(0),
+ nativeBalance: async () => BigInt(0),
+ waitForReceipt: async () => {
+ if (receipt instanceof Error) throw receipt
+ return { status: 'success', from: ACCOUNT, to: CONTRACT, logs: [], ...receipt } as EvmReceipt
+ },
+ transaction: async () => {
+ if (sent instanceof Error) throw sent
+ return sent
+ },
+ }
+}
+
+const SIGNED = { family: 'evm' as const, hash: HASH }
+
+describe('confirmEvmCall', () => {
+ it('settles a landed call that is this venue’s, from this person', async () => {
+ const result = await confirmEvmCall(POLICY, ACCOUNT, SIGNED, fakeClient({}))
+ expect(result).toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('says funds did not move only for a proven revert of this very call', async () => {
+ const result = await confirmEvmCall(POLICY, ACCOUNT, SIGNED, fakeClient({ status: 'reverted' }))
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it.each<[string, EvmClient]>([
+ ['a receipt that has not come', fakeClient(new Error('timeout'))],
+ ['another person’s transaction', fakeClient({ from: STRANGER })],
+ ['a contract creation', fakeClient({ to: null })],
+ ['another contract', fakeClient({ to: OTHER_CONTRACT })],
+ ['a transaction that can’t be read', fakeClient({}, new Error('rpc'))],
+ [
+ 'a call that credits someone else',
+ fakeClient({}, { input: put(STRANGER), value: BigInt(0) }),
+ ],
+ [
+ 'a revert that is not this call',
+ fakeClient({ status: 'reverted' }, { input: put(STRANGER), value: BigInt(0) }),
+ ],
+ ])('stays pending for %s, since the money may have moved', async (_label, client) => {
+ const result = await confirmEvmCall(POLICY, ACCOUNT, SIGNED, client)
+ expect(result).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('stays pending when the wallet gave no usable hash', async () => {
+ const result = await confirmEvmCall(
+ POLICY,
+ ACCOUNT,
+ { family: 'evm', hash: 'nope' },
+ fakeClient({})
+ )
+ expect(result).toMatchObject({ ok: false, pending: true })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/evm-exits.live.test.ts b/apps/dapp/lib/__tests__/evm-exits.live.test.ts
new file mode 100644
index 00000000..a4ea948a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/evm-exits.live.test.ts
@@ -0,0 +1,543 @@
+import {
+ decodeFunctionData,
+ decodeFunctionResult,
+ encodeAbiParameters,
+ encodeFunctionData,
+ getAddress,
+ http,
+ keccak256,
+ maxUint256,
+ pad,
+ parseAbi,
+ toHex,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ AAVE_AVALANCHE_MARKET,
+ AAVE_MONAD_MARKET,
+ createAaveVenue,
+ NEVERLAND_MARKET,
+ type AaveMarket,
+} from '../chains/evm/aave'
+import { createBeefyMonadVenue } from '../chains/evm/beefy'
+import type { EvmReaderClient } from '../chains/evm/call'
+import { evmReader } from '../chains/evm/client'
+import { createCurvanceVenue } from '../chains/evm/curvance'
+import { createEulerAvalancheVenue, createEulerMonadVenue } from '../chains/evm/euler'
+import { createFastlaneVenue, SHMON } from '../chains/evm/fastlane'
+import { createMorphoVenue, MORPHO_VAULT } from '../chains/evm/morpho'
+import { createSablierVenue, SABLIER_LOCKUP, SABLIER_LOCKUP_ABI } from '../chains/evm/sablier'
+import { EVM_CHAINS, type EvmChain } from '../chains/evm/tokens'
+import type { ChainActionRequest, ChainVenue, SigningPayload } from '../chains/types'
+
+/**
+ * Every exit, built for real holders and tried with `eth_call` as them. Nothing signs or sends, and
+ * nobody here has their keys. Holders move: when one leaves, its case fails on balance, so swap in
+ * another from the venue's token transfers.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 180_000
+
+const RPC: Record = {
+ avalanche: process.env['AVALANCHE_RPC_URL'] ?? 'https://avalanche-c-chain-rpc.publicnode.com',
+ monad: process.env['MONAD_RPC_URL'] ?? 'https://rpc1.monad.xyz',
+}
+
+function reader(chain: EvmChain): EvmReaderClient {
+ return evmReader(chain, http(RPC[chain], { timeout: 30_000, retryCount: 6, retryDelay: 2_000 }))
+}
+
+const avalanche = reader('avalanche')
+const monad = reader('monad')
+const READERS: Record = { avalanche, monad }
+
+// Circle's FiatToken keeps balances at slot 9 and allowances at slot 10.
+const USDC_BALANCE_SLOT = BigInt(9)
+const USDC_ALLOWANCE_SLOT = BigInt(10)
+const LOTS = BigInt(10) ** BigInt(15)
+
+function mapSlot(key: Address | Hex, slot: bigint | Hex): Hex {
+ const keyType = key.length === 42 ? 'address' : 'bytes32'
+ const slotType = typeof slot === 'bigint' ? 'uint256' : 'bytes32'
+ return keccak256(
+ encodeAbiParameters([{ type: keyType }, { type: slotType }], [key, slot] as never)
+ )
+}
+
+function revertDataOf(error: unknown): string {
+ let current = error as { data?: unknown; cause?: unknown } | undefined
+ while (current !== undefined && current !== null) {
+ if (typeof current.data === 'string') return current.data
+ current = current.cause as typeof current
+ }
+ return ''
+}
+
+function asEvm(signing: SigningPayload) {
+ if (signing.family !== 'evm') throw new Error('Expected an EVM payload.')
+ return signing
+}
+
+const POOL = parseAbi([
+ 'function withdraw(address asset, uint256 amount, address to) returns (uint256)',
+ 'function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) returns (uint256)',
+ 'function getConfiguration(address asset) view returns (uint256)',
+])
+
+const whole = (kind: ChainActionRequest['kind'], tokenIn: string, amount: string) => ({
+ kind,
+ tokenIn,
+ amount,
+ wholePosition: true,
+})
+
+function positionOf(venue: ChainVenue, account: string, slot: string, symbol: string) {
+ return venue.positions?.(account).then((all) => {
+ const found = all.find((p) => p.slot === slot && p.symbol === symbol)
+ if (found === undefined) throw new Error(`${account} holds no ${symbol} in ${slot}`)
+ return found.amount
+ })
+}
+
+interface AaveCase {
+ name: string
+ market: AaveMarket
+ /** Holds USDC supplied and nothing borrowed. */
+ supplier: Address
+ /** Owes USDC. */
+ borrower: Address
+}
+
+const AAVE_CASES: AaveCase[] = [
+ {
+ name: 'Aave on Avalanche',
+ market: AAVE_AVALANCHE_MARKET,
+ supplier: '0x0f9274F72a787B612A17FC22dd4e1894F442A6c8',
+ borrower: '0xF5415033F7211eA19bA2A448eC04db9C99eAd270',
+ },
+ {
+ name: 'Aave on Monad',
+ market: AAVE_MONAD_MARKET,
+ // Beefy's strategy, the largest supplier here.
+ supplier: '0x6105E2bF96283800781d31C6Ad18c6FBBde688CD',
+ borrower: '0xCf0a12CBd8088fc5f84ad431E71787157041cD69',
+ },
+ {
+ name: 'Neverland on Monad',
+ market: NEVERLAND_MARKET,
+ supplier: '0x8d5c2Df3Eef09088Fcccf3376D8EcD0Dd505f642',
+ borrower: '0x1111715A738b867A2B92B2fab52AC96e3D511111',
+ },
+]
+
+describe.skipIf(SKIP).each(AAVE_CASES)('$name exits, live', ({ market, supplier, borrower }) => {
+ const chainReader = READERS[market.chain]
+ const venue = createAaveVenue(market, chainReader)
+ const usdc = market.tokens.find((t) => t.symbol === 'USDC')?.id as Address
+ const pool = market.pool as Address
+
+ it(
+ 'takes all of a real supply with the Pool’s max, paying the holder at least what was read',
+ async () => {
+ const amount = (await positionOf(venue, supplier, 'supplied', 'USDC')) as string
+ const prepared = await venue.prepare(whole('withdraw', 'USDC', amount), supplier)
+ const signing = asEvm(prepared.signing)
+ expect(() => venue.assertSafe(signing, supplier)).not.toThrow()
+ const { args } = decodeFunctionData({ abi: POOL, data: signing.data as Hex })
+ expect(args?.[1]).toBe(maxUint256)
+ const result = await chainReader.call({
+ account: supplier,
+ to: pool,
+ data: signing.data as Hex,
+ })
+ const paid = decodeFunctionResult({
+ abi: POOL,
+ functionName: 'withdraw',
+ data: result.data as Hex,
+ })
+ expect(paid).toBeGreaterThanOrEqual(BigInt(Math.floor(Number(amount) * 1e6)))
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'repays all of a real loan, taking only the debt though it names a little more',
+ async () => {
+ const amount = (await positionOf(venue, borrower, 'borrowed', 'USDC')) as string
+ // The wallet may not hold the debt, so it is funded for the build and for this one call below.
+ const funded = {
+ ...chainReader,
+ readContract: (args: { functionName: string }) => {
+ if (args.functionName === 'balanceOf') return Promise.resolve(LOTS)
+ if (args.functionName === 'allowance') return Promise.resolve(BigInt(0))
+ return chainReader.readContract(args as never)
+ },
+ } as unknown as EvmReaderClient
+ const built = createAaveVenue(market, funded)
+ const prepared = await built.prepare(whole('repay', 'USDC', amount), borrower)
+ const signing = asEvm(prepared.signing)
+ expect(signing.approval?.to).toBe(usdc)
+ expect(() => venue.assertSafe(signing, borrower)).not.toThrow()
+
+ const { args } = decodeFunctionData({ abi: POOL, data: signing.data as Hex })
+ const named = args?.[1] as bigint
+ const owed = BigInt(Math.round(Number(amount) * 1e6))
+ expect(named).toBeGreaterThan(owed)
+
+ const allowance = mapSlot(pool, mapSlot(borrower, USDC_ALLOWANCE_SLOT))
+ const result = await chainReader.call({
+ account: borrower,
+ to: pool,
+ data: signing.data as Hex,
+ stateOverride: [
+ {
+ address: usdc,
+ stateDiff: [
+ {
+ slot: mapSlot(borrower, USDC_BALANCE_SLOT),
+ value: pad(toHex(named * BigInt(2))),
+ },
+ { slot: allowance, value: pad(toHex(named)) },
+ ],
+ },
+ ],
+ })
+ const took = decodeFunctionResult({
+ abi: POOL,
+ functionName: 'repay',
+ data: result.data as Hex,
+ })
+ expect(took).toBeLessThan(named)
+ expect(took).toBeGreaterThanOrEqual(owed)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'lets a withdraw through a frozen reserve and stops at a paused one, on the real code',
+ async () => {
+ const word = await chainReader.readContract({
+ address: pool,
+ abi: POOL,
+ functionName: 'getConfiguration',
+ args: [usdc],
+ })
+ // `ReserveData.configuration` opens the reserve's struct in `_reserves`, at slot 52.
+ const slot = mapSlot(usdc, BigInt(52))
+ const stored = await chainReader.getStorageAt({ address: pool, slot })
+ expect(BigInt(stored ?? '0x0')).toBe(word)
+ const FROZEN_BIT = BigInt(1) << BigInt(57)
+ const PAUSED_BIT = BigInt(1) << BigInt(60)
+ const tryWith = (bit: bigint) =>
+ chainReader.call({
+ account: supplier,
+ to: pool,
+ data: encodeFunctionData({
+ abi: POOL,
+ functionName: 'withdraw',
+ args: [usdc, maxUint256, supplier],
+ }),
+ stateOverride: [
+ {
+ address: pool,
+ stateDiff: [{ slot, value: pad(toHex(word | bit), { size: 32 }) }],
+ },
+ ],
+ })
+ await expect(tryWith(FROZEN_BIT)).resolves.toBeDefined()
+ // ReservePaused() on Aave V3, and code 29 on the older code Neverland runs.
+ const paused = await tryWith(PAUSED_BIT).then(
+ () => 'it went through',
+ (error: unknown) => String((error as Error).message) + revertDataOf(error)
+ )
+ expect(paused).toMatch(/d37f5f1c|reason: 29/)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('Aave collateral that counts for nothing, live', () => {
+ it(
+ 'blocks other collateral on the real Pool, and the venue puts that error into words',
+ async () => {
+ // Posts USDC, which counts for nothing toward a loan on this market, with WETH.e and a loan.
+ const borrower = '0x04249935b89230B1aA1554C144a288b931E83BaA'
+ // This loan is under the app's own floor, which would refuse first, so that check reads no
+ // debt here. The Pool still sees the real account, and its real error comes back.
+ const pastTheFloor = {
+ ...avalanche,
+ readContract: async (args: { functionName: string }) => {
+ const read = await avalanche.readContract(args as never)
+ if (args.functionName !== 'getUserAccountData') return read
+ const [collateral, , ...rest] = read as readonly bigint[]
+ return [collateral, BigInt(0), ...rest]
+ },
+ } as unknown as EvmReaderClient
+ const venue = createAaveVenue(AAVE_AVALANCHE_MARKET, pastTheFloor)
+ await expect(
+ venue.prepare({ kind: 'withdraw', amount: '0.000000000001', tokenIn: 'WETH.e' }, borrower)
+ ).rejects.toThrow(/withdraw that token first.*Nothing was signed or sent\.$/)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('Aave collateral that backs a loan, live', () => {
+ it(
+ 'refuses all of it in plain words for a real borrower',
+ async () => {
+ const borrower = '0x58EA58212b66A33f566F2333bc5D398FA6F825d9'
+ const venue = createAaveVenue(AAVE_AVALANCHE_MARKET, avalanche)
+ const amount = (await positionOf(venue, borrower, 'supplied', 'WAVAX')) as string
+ await expect(venue.prepare(whole('withdraw', 'WAVAX', amount), borrower)).rejects.toThrow(
+ /backs your loan.*Nothing was signed or sent\.$/
+ )
+ },
+ SLOW_MS
+ )
+})
+
+const REDEEM = parseAbi([
+ 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
+ 'function balanceOf(address owner) view returns (uint256)',
+])
+
+async function expectRedeemsAll(
+ venue: ChainVenue,
+ holder: Address,
+ vault: Address,
+ client: EvmReaderClient
+) {
+ const amount = (await positionOf(venue, holder, 'supplied', 'USDC')) as string
+ const prepared = await venue.prepare(whole('withdraw', 'USDC', amount), holder)
+ const signing = asEvm(prepared.signing)
+ expect(() => venue.assertSafe(signing, holder)).not.toThrow()
+ const shares = await client.readContract({
+ address: vault,
+ abi: REDEEM,
+ functionName: 'balanceOf',
+ args: [holder],
+ })
+ const { functionName, args } = decodeFunctionData({ abi: REDEEM, data: signing.data as Hex })
+ expect(functionName).toBe('redeem')
+ expect(args).toEqual([shares, getAddress(holder), getAddress(holder)])
+ const result = await client.call({ account: holder, to: vault, data: signing.data as Hex })
+ const paid = decodeFunctionResult({
+ abi: REDEEM,
+ functionName: 'redeem',
+ data: result.data as Hex,
+ })
+ // The read figure is the live one, so what comes out sits on it, within a hair of interest.
+ const read = Number(amount)
+ expect(Number(paid) / 1e6).toBeGreaterThanOrEqual(read * 0.9999)
+ expect(Number(paid) / 1e6).toBeLessThanOrEqual(read * 1.0001)
+}
+
+describe.skipIf(SKIP)('ERC-4626 exits, live', () => {
+ it(
+ 'Morpho redeems every share of a real holder',
+ () =>
+ expectRedeemsAll(
+ createMorphoVenue({ reader: monad }),
+ '0x255c7705e8BB334DfCae438197f7C4297988085a',
+ MORPHO_VAULT.address as Address,
+ monad
+ ),
+ SLOW_MS
+ )
+
+ it(
+ 'Euler redeems every share of a real holder',
+ () =>
+ expectRedeemsAll(
+ createEulerAvalancheVenue({ reader: avalanche }),
+ '0xf3a05b89b8f857A5aD6532C8e7c4584b0d80DDF2',
+ '0x37ca03aD51B8ff79aAD35FadaCBA4CEDF0C3e74e',
+ avalanche
+ ),
+ SLOW_MS
+ )
+
+ it(
+ 'Curvance redeems every share of a real holder, at the rate it read',
+ () =>
+ expectRedeemsAll(
+ createCurvanceVenue(() => monad),
+ '0x17406facecB4f0c4389634B79ed2529D2c967981',
+ '0x8EE9FC28B8Da872c38A496e9dDB9700bb7261774',
+ monad
+ ),
+ SLOW_MS
+ )
+
+ it(
+ 'Curvance shows posted collateral, and refuses all of it until it comes off',
+ async () => {
+ const holder = '0xf2aCD72302977fB4bCfC9B90389a040239e2aE7e'
+ const venue = createCurvanceVenue(() => monad)
+ const amount = (await positionOf(venue, holder, 'supplied', 'USDC')) as string
+ await expect(venue.prepare(whole('withdraw', 'USDC', amount), holder)).rejects.toThrow(
+ /remove the collateral/
+ )
+ },
+ SLOW_MS
+ )
+
+ it.each<[string, ChainVenue, Address]>([
+ [
+ 'Euler on Monad, whose largest holder holds more than it has free',
+ createEulerMonadVenue({ reader: monad }),
+ '0xE1BcA19baA63894D374578320551633320436523',
+ ],
+ [
+ 'Euler on Avalanche, likewise',
+ createEulerAvalancheVenue({ reader: avalanche }),
+ '0x4aF3aBE954259fb70b97C57EBD7Ac1eb822028Ef',
+ ],
+ ])(
+ '%s, says what it can pay',
+ async (_name, venue, holder) => {
+ const amount = (await positionOf(venue, holder, 'supplied', 'USDC')) as string
+ await expect(venue.prepare(whole('withdraw', 'USDC', amount), holder)).rejects.toThrow(
+ /can pay out about/
+ )
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('Beefy exit, live', () => {
+ it(
+ 'builds withdrawAll for a real holder, and it runs',
+ async () => {
+ const holder = '0x9b93760f95ABc7ee4913b4Fb6e169719D62c50AF'
+ const venue = createBeefyMonadVenue(() => monad)
+ const amount = (await positionOf(venue, holder, 'supplied', 'USDC')) as string
+ const prepared = await venue.prepare(whole('withdraw', 'USDC', amount), holder)
+ const signing = asEvm(prepared.signing)
+ expect(signing.data).toBe('0x853828b6')
+ expect(() => venue.assertSafe(signing, holder)).not.toThrow()
+ await monad.call({ account: holder, to: signing.to as Address, data: signing.data as Hex })
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('FastLane exit, live', () => {
+ it(
+ 'exits every shMON of a real holder, paying at least the minimum',
+ async () => {
+ const holder = '0x50649A1ea82f3bD8659b5D8f5A17DdC0b0Ee7ccf'
+ const venue = createFastlaneVenue(monad)
+ const amount = (await positionOf(venue, holder, 'staked', 'shMON')) as string
+ const prepared = await venue.prepare(whole('unstake', 'shMON', amount), holder)
+ const signing = asEvm(prepared.signing)
+ expect(signing.to).toBe(SHMON)
+ expect(() => venue.assertSafe(signing, holder)).not.toThrow()
+ await monad.call({ account: holder, to: SHMON as Address, data: signing.data as Hex })
+ },
+ SLOW_MS
+ )
+})
+
+const STREAM_VIEWS = parseAbi([
+ 'function refundableAmountOf(uint256 streamId) view returns (uint128)',
+])
+
+const SENDER_OF_STREAM_7 = '0xf26994E6Af0b95cCa8DFA22A0BC25E1f38a54C42'
+// Streams that have run out, in tokens this app lists, on each chain.
+const SPENT: Record = {
+ avalanche: { id: '10', sender: '0xe135D9F010f5D0ED203E3f3fC47F66e2ECEd721d' },
+ monad: { id: '15', sender: '0xE46DD62eCB9B9BD5FfC545E8826Fc5fF18Eab8f2' },
+}
+
+describe.skipIf(SKIP).each(['avalanche', 'monad'])(
+ 'Sablier cancel on %s, live',
+ (chain) => {
+ const client = READERS[chain]
+ const venue = createSablierVenue(chain, client)
+ const lockup = SABLIER_LOCKUP[chain] as Address
+ const data = encodeFunctionData({
+ abi: SABLIER_LOCKUP_ABI,
+ functionName: 'cancel',
+ args: [BigInt(7)],
+ })
+ const cancel = (streamId: string): ChainActionRequest => ({
+ kind: 'cancel',
+ amount: 'all',
+ tokenIn: 'STREAM',
+ streamId,
+ })
+
+ it(
+ 'runs for the real sender with no value, refunding what the contract says is refundable',
+ async () => {
+ const refundable = await client.readContract({
+ address: lockup,
+ abi: STREAM_VIEWS,
+ functionName: 'refundableAmountOf',
+ args: [BigInt(7)],
+ })
+ const result = await client.call({ account: SENDER_OF_STREAM_7, to: lockup, data })
+ const refund = decodeFunctionResult({
+ abi: SABLIER_LOCKUP_ABI,
+ functionName: 'cancel',
+ data: result.data as Hex,
+ })
+ expect(refund).toBeGreaterThan(BigInt(0))
+ expect(Number(refund)).toBeCloseTo(Number(refundable), -13)
+ expect(() =>
+ venue.assertSafe(
+ { family: 'evm', chainId: EVM_CHAINS[chain].chain.id, to: lockup, data, value: '0' },
+ SENDER_OF_STREAM_7
+ )
+ ).not.toThrow()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'is refused by the contract for anyone else',
+ async () => {
+ await expect(
+ client.call({ account: '0x000000000000000000000000000000000000bEEF', to: lockup, data })
+ ).rejects.toThrow()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'refuses in plain words a stream that isn’t yours, is spent, or is in an unlisted token',
+ async () => {
+ await expect(
+ venue.prepare(cancel('7'), '0x000000000000000000000000000000000000bEEF')
+ ).rejects.toThrow(/wasn't started by this wallet/)
+ await expect(venue.prepare(cancel('7'), SENDER_OF_STREAM_7)).rejects.toThrow(
+ /a token I haven't confirmed/
+ )
+ await expect(venue.prepare(cancel(SPENT[chain].id), SPENT[chain].sender)).rejects.toThrow(
+ /finished streaming/
+ )
+ await expect(venue.prepare(cancel('999999'), SENDER_OF_STREAM_7)).rejects.toThrow(
+ /There is no stream/
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads every stream to list a sender’s, and lists no token it can’t confirm',
+ async () => {
+ await expect(venue.positions?.(SENDER_OF_STREAM_7)).resolves.toEqual([])
+ await expect(
+ venue.positions?.('0x000000000000000000000000000000000000bEEF')
+ ).resolves.toEqual([])
+ },
+ SLOW_MS
+ )
+ }
+)
diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts
index 583b30e9..730b1c74 100644
--- a/apps/dapp/lib/__tests__/evm-module.test.ts
+++ b/apps/dapp/lib/__tests__/evm-module.test.ts
@@ -326,21 +326,54 @@ describe('the chain modules', () => {
expect(EVM_CHAINS.avalanche.chain.id).toBe(43114)
})
- it('finds a module for both, each with its own swap venue and a wallet transfer only', () => {
+ it('finds a module for both, each with its own venues and a wallet transfer', () => {
const avalancheModule = chainModule('avalanche')
const monadModule = chainModule('monad')
expect(avalancheModule?.chain).toBe('avalanche')
expect(monadModule?.chain).toBe('monad')
- expect(avalancheModule?.venues.map((v) => v.id)).toEqual(['1click-avalanche', 'send'])
- expect(monadModule?.venues.map((v) => v.id)).toEqual(['1click-monad', 'send'])
+ expect(avalancheModule?.venues.map((v) => v.id)).toEqual([
+ '1click-avalanche',
+ 'uniswap-avalanche',
+ 'pharaoh',
+ 'aave-avalanche',
+ 'sablier-avalanche',
+ 'bulla',
+ 'send',
+ ])
+ expect(monadModule?.venues.map((v) => v.id)).toEqual([
+ '1click-monad',
+ 'uniswap-monad',
+ 'aave-monad',
+ 'neverland',
+ 'morpho',
+ 'euler-monad',
+ 'curvance',
+ 'beefy-monad',
+ 'fastlane',
+ 'sablier-monad',
+ 'send',
+ ])
expect(avalancheModule?.defaultVenue.swap).toBe('1click-avalanche')
expect(monadModule?.defaultVenue.swap).toBe('1click-monad')
expect(chainModule('arc')).toBeUndefined()
})
it('lists the tokens the brief names, with the contracts 1Click lists', () => {
- expect(AVALANCHE_TOKENS.map((t) => t.symbol)).toEqual(['AVAX', 'USDC', 'USDT'])
- expect(MONAD_TOKENS.map((t) => t.symbol)).toEqual(['MON', 'USDC', 'USDT0'])
+ expect(AVALANCHE_TOKENS.map((t) => t.symbol)).toEqual([
+ 'AVAX',
+ 'USDC',
+ 'USDT',
+ 'WAVAX',
+ 'WETH.e',
+ ])
+ expect(MONAD_TOKENS.map((t) => t.symbol)).toEqual([
+ 'MON',
+ 'USDC',
+ 'USDT0',
+ 'WMON',
+ 'WETH',
+ 'shMON',
+ ])
expect(AVAX_USDC.id.toLowerCase().startsWith('0xb97ef9ef8734')).toBe(true)
expect(MONAD_USDC.id.toLowerCase().startsWith('0x754704bc059f')).toBe(true)
expect(AVAX.id).toBe(EVM_NATIVE_ID)
@@ -349,11 +382,25 @@ describe('the chain modules', () => {
expect(MONAD_USDC.decimals).toBe(6)
})
- it('offers no venue without the secret that protects what would be issued', () => {
- expect(buildEvmVenues('avalanche', {})).toEqual([])
- expect(buildEvmVenues('monad', { AUTH_SECRET: 'short' })).toEqual([])
- expect(buildEvmVenues('monad', { AUTH_SECRET: 'x'.repeat(32) }).map((v) => v.id)).toEqual([
+ it('keeps 1Click and sends behind the secret, and the onchain venues out from behind it', () => {
+ const ids = (chain: 'avalanche' | 'monad', env: Record): string[] =>
+ buildEvmVenues(chain, env).map((v) => v.id)
+ expect(ids('avalanche', {})).not.toContain('1click-avalanche')
+ expect(ids('avalanche', {})).not.toContain('send')
+ expect(ids('avalanche', {})).toContain('aave-avalanche')
+ expect(ids('monad', { AUTH_SECRET: 'short' })).not.toContain('1click-monad')
+ expect(ids('monad', { AUTH_SECRET: 'short' })).toContain('aave-monad')
+ expect(ids('monad', { AUTH_SECRET: 'x'.repeat(32) })).toEqual([
'1click-monad',
+ 'uniswap-monad',
+ 'aave-monad',
+ 'neverland',
+ 'morpho',
+ 'euler-monad',
+ 'curvance',
+ 'beefy-monad',
+ 'fastlane',
+ 'sablier-monad',
'send',
])
})
@@ -438,7 +485,9 @@ describe('the race on these chains', () => {
})
expect(capabilitySheet('avalanche')).toContain('Agents compete')
expect(capabilitySheet('monad')).toContain('Agents compete')
- expect(capabilitySheet('monad')).not.toMatch(/Supply:|Stake:|limit order/)
+ expect(capabilitySheet('monad')).toContain('Supply:')
+ expect(capabilitySheet('monad')).toContain('Stake:')
+ expect(capabilitySheet('monad')).not.toMatch(/limit order/)
expect(capabilitySheet('arc')).not.toContain('Agents compete')
})
@@ -536,7 +585,7 @@ describe('naming the chain you are on', () => {
['monad', 'Monad'],
])('builds on %s when told "on %s"', async (slug, venue) => {
const request = swap({ tokenOut: toNative[slug], venue })
- const prepared = await prepareChainAction(moduleFor(slug), request, ACCOUNT, {})
+ const prepared = await prepareChainAction(moduleFor(slug), request, ACCOUNT)
expect(prepared.venueId).toBe(`1click-${slug}`)
})
@@ -545,7 +594,7 @@ describe('naming the chain you are on', () => {
async (venue) => {
const request = swap({ tokenOut: 'MON', venue })
await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([])
- await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow(
+ await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT)).rejects.toThrow(
/can't swap here/
)
expect(sim.quotes.filter((q) => q['dry'] === false)).toEqual([])
@@ -558,7 +607,7 @@ describe('naming the chain you are on', () => {
const request = swap({ tokenOut: 'MON', venue })
const routes = await quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)
expect(routes.map((r) => r.id)).toEqual(['1click-monad-1'])
- const prepared = await prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})
+ const prepared = await prepareChainAction(moduleFor('monad'), request, ACCOUNT)
expect(prepared.venueId).toBe('1click-monad')
}
)
@@ -566,7 +615,7 @@ describe('naming the chain you are on', () => {
it('refuses a venue that isn’t here', async () => {
const request = swap({ tokenOut: 'MON', venue: 'jupiter' })
await expect(quoteChainRoutes(moduleFor('monad'), request, ACCOUNT)).resolves.toEqual([])
- await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT, {})).rejects.toThrow(
+ await expect(prepareChainAction(moduleFor('monad'), request, ACCOUNT)).rejects.toThrow(
/"jupiter" can't swap here/
)
})
@@ -579,7 +628,7 @@ describe('naming the chain you are on', () => {
const api = twoVenues(chain)
const routes = await quoteChainRoutes(api, ab(venue), ACCOUNT)
expect(routes.map((r) => r.id)).toEqual(['alpha-1', 'beta-1'])
- await expect(prepareChainAction(api, ab(venue), ACCOUNT, {})).resolves.toMatchObject({
+ await expect(prepareChainAction(api, ab(venue), ACCOUNT)).resolves.toMatchObject({
venueId: 'beta',
})
})
@@ -588,11 +637,11 @@ describe('naming the chain you are on', () => {
const api = twoVenues('solana')
const routes = await quoteChainRoutes(api, ab('alpha'), ACCOUNT)
expect(routes.map((r) => r.id)).toEqual(['alpha-1'])
- await expect(prepareChainAction(api, ab('alpha'), ACCOUNT, {})).resolves.toMatchObject({
+ await expect(prepareChainAction(api, ab('alpha'), ACCOUNT)).resolves.toMatchObject({
venueId: 'alpha',
})
await expect(quoteChainRoutes(api, ab('monad'), ACCOUNT)).resolves.toEqual([])
- await expect(prepareChainAction(api, ab('monad'), ACCOUNT, {})).rejects.toThrow(
+ await expect(prepareChainAction(api, ab('monad'), ACCOUNT)).rejects.toThrow(
/"monad" can't swap here/
)
})
@@ -603,8 +652,26 @@ describe('the Apps page rows', () => {
venues.filter((v) => venueOnChain(v, CHAIN_DESCRIPTORS[slug])).map((v) => v.id)
it('shows each chain its own row and no venue that does not name it', () => {
- expect(on('avalanche')).toEqual(['1click-avalanche'])
- expect(on('monad')).toEqual(['1click-monad'])
+ expect(on('avalanche')).toEqual([
+ '1click-avalanche',
+ 'uniswap-avalanche',
+ 'pharaoh',
+ 'aave-avalanche',
+ 'sablier-avalanche',
+ 'bulla',
+ ])
+ expect(on('monad')).toEqual([
+ '1click-monad',
+ 'uniswap-monad',
+ 'aave-monad',
+ 'neverland',
+ 'morpho',
+ 'euler-monad',
+ 'curvance',
+ 'beefy-monad',
+ 'fastlane',
+ 'sablier-monad',
+ ])
})
it('changes nothing for another family or for Arc', () => {
@@ -815,8 +882,7 @@ describe('prepare', () => {
const prepared = await prepareChainAction(
monadModule,
swap({ tokenIn: 'MON', tokenOut: 'USDT0', amount: '100' }),
- ACCOUNT,
- {}
+ ACCOUNT
)
expect(prepared.venueId).toBe('1click-monad')
expect(prepared.signing).toMatchObject({
diff --git a/apps/dapp/lib/__tests__/exit-routing.test.ts b/apps/dapp/lib/__tests__/exit-routing.test.ts
new file mode 100644
index 00000000..25fa312a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/exit-routing.test.ts
@@ -0,0 +1,256 @@
+import { describe, expect, it } from 'vitest'
+
+import { parseChainAction } from '../chains/parse-action'
+import { prepareChainAction } from '../chains/prepare'
+import {
+ ChainActionRefused,
+ type ChainActionRequest,
+ type ChainModule,
+ type ChainVenue,
+ type PreparedAction,
+ type VenuePosition,
+} from '../chains/types'
+
+const USDC = { symbol: 'USDC', id: 'usdc', decimals: 6, name: 'USD Coin' }
+
+function venue(
+ id: string,
+ positions: VenuePosition[] | 'unread' | 'none',
+ seen: ChainActionRequest[] = []
+): ChainVenue {
+ return {
+ id,
+ name: id,
+ kinds: ['withdraw', 'repay', 'unstake', 'claim', 'cancel'],
+ ...(positions === 'none'
+ ? {}
+ : {
+ positions: async () => {
+ if (positions === 'unread') throw new Error('rpc down')
+ return positions
+ },
+ }),
+ prepare: async (request) => {
+ seen.push(request)
+ return { venueId: id, venueName: id, kind: request.kind } as unknown as PreparedAction
+ },
+ assertSafe: () => undefined,
+ }
+}
+
+function moduleWith(venues: ChainVenue[]): ChainModule {
+ return {
+ chain: 'sui',
+ tokens: [USDC],
+ venues,
+ defaultVenue: { withdraw: 'a' },
+ priceUsd: async () => 1,
+ submit: async () => ({ ok: true, hash: 'h', explorerUrl: 'u' }),
+ balances: async () => ({ tokens: [] }),
+ } as unknown as ChainModule
+}
+
+const held = (amount: string): VenuePosition[] => [{ slot: 'supplied', symbol: 'USDC', amount }]
+const withdraw = (amount: string, name?: string): ChainActionRequest => ({
+ kind: 'withdraw',
+ amount,
+ tokenIn: 'USDC',
+ ...(name !== undefined ? { venue: name } : {}),
+})
+
+describe('reading an exit', () => {
+ it.each([
+ ['withdraw all USDC', 'withdraw', undefined],
+ ['withdraw all my USDC from Aave', 'withdraw', 'Aave'],
+ ['withdraw my USDC', 'withdraw', undefined],
+ ['take out all USDC on morpho', 'withdraw', 'morpho'],
+ ['unstake all sSUI', 'unstake', undefined],
+ ['repay all USDC on Aave', 'repay', 'Aave'],
+ ['pay off my USDC', 'repay', undefined],
+ ['claim my USDC', 'claim', undefined],
+ ['collect USDC from solayer', 'claim', 'solayer'],
+ ])('reads %j as the whole of it', (text, kind, named) => {
+ expect(parseChainAction(text)).toMatchObject({ kind, amount: 'all', venue: named })
+ })
+
+ it('keeps a number a number', () => {
+ expect(parseChainAction('withdraw 25 USDC')).toMatchObject({ kind: 'withdraw', amount: '25' })
+ })
+
+ it('keeps the case of a stream id', () => {
+ expect(parseChainAction('cancel stream AbC123 on streamflow')).toMatchObject({
+ kind: 'cancel',
+ streamId: 'AbC123',
+ venue: 'streamflow',
+ })
+ })
+})
+
+describe('where an exit goes', () => {
+ it('goes to the one venue holding it, whatever the default is', async () => {
+ const out = await prepareChainAction(
+ moduleWith([venue('a', []), venue('b', held('5'))]),
+ withdraw('5'),
+ 'me'
+ )
+ expect(out.venueId).toBe('b')
+ })
+
+ it('asks when two hold it, and says what each holds', async () => {
+ await expect(
+ prepareChainAction(
+ moduleWith([venue('a', held('1')), venue('b', held('2'))]),
+ withdraw('1'),
+ 'me'
+ )
+ ).rejects.toThrow(/1 on a and 2 on b/)
+ })
+
+ it('says so when it can read every venue and none holds it', async () => {
+ const attempt = prepareChainAction(
+ moduleWith([venue('a', []), venue('b', [])]),
+ withdraw('1'),
+ 'me'
+ )
+ await expect(attempt).rejects.toThrow(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/don't see any USDC/)
+ })
+
+ it.each(['unread', 'none'] as const)(
+ 'falls back to the default, not a refusal, when a venue reads as %s',
+ async (other) => {
+ const out = await prepareChainAction(
+ moduleWith([venue('a', []), venue('b', other)]),
+ withdraw('1'),
+ 'me'
+ )
+ expect(out.venueId).toBe('a')
+ }
+ )
+
+ it('never reads positions to overrule a venue the person named', async () => {
+ const out = await prepareChainAction(
+ moduleWith([venue('a', held('9')), venue('b', [])]),
+ withdraw('1', 'b'),
+ 'me'
+ )
+ expect(out.venueId).toBe('b')
+ })
+})
+
+describe('an exit already under way', () => {
+ const cooling: VenuePosition = {
+ slot: 'cooling',
+ symbol: 'USDC',
+ amount: '4',
+ readyInHours: 11.2,
+ }
+ const ready: VenuePosition = { slot: 'claimable', symbol: 'USDC', amount: '6' }
+ const unstake = (amount: string): ChainActionRequest => ({
+ kind: 'unstake',
+ amount,
+ tokenIn: 'USDC',
+ })
+
+ it.each([
+ ['cooling', cooling],
+ ['ready', ready],
+ ])('sends an unstake to the venue holding what is %s, not "none anywhere"', async (_, held) => {
+ const out = await prepareChainAction(
+ moduleWith([venue('a', []), venue('b', [held])]),
+ unstake('4'),
+ 'me'
+ )
+ expect(out.venueId).toBe('b')
+ })
+
+ it('sends a claim to the venue still cooling, so it can say how long is left', async () => {
+ const out = await prepareChainAction(
+ moduleWith([venue('a', []), venue('b', [cooling])]),
+ { kind: 'claim', amount: 'all', tokenIn: 'USDC' },
+ 'me'
+ )
+ expect(out.venueId).toBe('b')
+ })
+
+ it('still does not send a withdraw to a venue only cooling', async () => {
+ await expect(
+ prepareChainAction(moduleWith([venue('a', []), venue('b', [cooling])]), withdraw('1'), 'me')
+ ).rejects.toThrow(/don't see any USDC/)
+ })
+
+ it('says what is ready and what is on its way when "all" finds nothing left to start', async () => {
+ const attempt = prepareChainAction(
+ moduleWith([venue('a', [ready, cooling])]),
+ unstake('all'),
+ 'me'
+ )
+ await expect(attempt).rejects.toThrow(/6 USDC is ready, so say "claim my USDC"/)
+ await expect(attempt).rejects.toThrow(/4 USDC is on its way, ready in about 12 hours/)
+ })
+
+ it('offers a claim in words that read as one, when two venues hold it', async () => {
+ await expect(
+ prepareChainAction(
+ moduleWith([venue('a', [ready]), venue('b', [ready])]),
+ { kind: 'claim', amount: 'all', tokenIn: 'USDC' },
+ 'me'
+ )
+ ).rejects.toThrow(/"claim my USDC from a"/)
+ })
+})
+
+describe('all of it', () => {
+ it('reaches the venue as the live figure, marked whole', async () => {
+ const seen: ChainActionRequest[] = []
+ await prepareChainAction(
+ moduleWith([venue('a', held('12.5'), seen), venue('b', [])]),
+ withdraw('all'),
+ 'me'
+ )
+ expect(seen[0]).toMatchObject({ amount: '12.5', wholePosition: true })
+ })
+
+ it('refuses when the figure can\u2019t be read, rather than guessing', async () => {
+ await expect(
+ prepareChainAction(moduleWith([venue('a', 'unread')]), withdraw('all'), 'me')
+ ).rejects.toThrow(/couldn't read how much/)
+ })
+
+ it('refuses when there is none', async () => {
+ await expect(
+ prepareChainAction(moduleWith([venue('a', [])]), withdraw('all', 'a'), 'me')
+ ).rejects.toThrow(/no USDC to withdraw/)
+ })
+
+ it('refuses "all" on a kind with no position behind it', async () => {
+ await expect(
+ prepareChainAction(
+ moduleWith([venue('a', [])]),
+ { kind: 'swap', amount: 'all', tokenIn: 'USDC', tokenOut: 'USDC' },
+ 'me'
+ )
+ ).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('a claim and a cancel', () => {
+ it('leaves a claim for the venue to size', async () => {
+ const seen: ChainActionRequest[] = []
+ await prepareChainAction(
+ moduleWith([venue('a', [{ slot: 'claimable', symbol: 'USDC', amount: '3' }], seen)]),
+ { kind: 'claim', amount: 'all', tokenIn: 'USDC' },
+ 'me'
+ )
+ expect(seen[0]).toMatchObject({ kind: 'claim', amount: 'all' })
+ })
+
+ it('takes a cancel with no token to check', async () => {
+ const out = await prepareChainAction(
+ moduleWith([venue('a', [])]),
+ { kind: 'cancel', amount: 'all', tokenIn: 'STREAM', streamId: '12', venue: 'a' },
+ 'me'
+ )
+ expect(out.kind).toBe('cancel')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/fastlane.live.test.ts b/apps/dapp/lib/__tests__/fastlane.live.test.ts
new file mode 100644
index 00000000..0f646c3e
--- /dev/null
+++ b/apps/dapp/lib/__tests__/fastlane.live.test.ts
@@ -0,0 +1,224 @@
+import {
+ encodeFunctionData,
+ getAddress,
+ parseAbi,
+ parseAbiItem,
+ zeroAddress,
+ type Address,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { evmReader } from '../chains/evm/client'
+import { createFastlaneVenue, SHMON, shmonPriceUsd } from '../chains/evm/fastlane'
+
+/**
+ * Read-only, against Monad mainnet: nothing here signs or sends. These fail when FastLane upgrades
+ * or moves its keys, which is the cue to review the new code and the card's notes.
+ *
+ * The account is whoever recently received shMON and still holds it with some MON, found from the
+ * chain's logs, since any fixed holder can sell out. It is only ever read and dry-run.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 120_000
+const LOG_WINDOW = BigInt(100)
+const WINDOWS_TO_SCAN = 200
+const ENOUGH = BigInt(10) ** BigInt(17)
+
+const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
+const ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103'
+
+const IMPLEMENTATION = '0x8Ee4FF5FF50198dcbB7B882A0781F42724Dd095f'
+const PROXY_ADMIN = '0x00b16590295092f12F4cec655296B6129C49489C' as Address
+const TIMELOCK = '0x4B7f4A4316c44B6928FB449B14Bd21b9718932F3' as Address
+const OPERATIONS_SAFE = '0x48a3267BCc4Cf230e9Bc4cae0c11EEbDb9a6A687' as Address
+
+const ABI = parseAbi([
+ 'function symbol() view returns (string)',
+ 'function decimals() view returns (uint8)',
+ 'function owner() view returns (address)',
+ 'function balanceOf(address) view returns (uint256)',
+ 'function previewDeposit(uint256) view returns (uint256)',
+ 'function getMinDelay() view returns (uint256)',
+ 'function getThreshold() view returns (uint256)',
+ 'function getFeeCurveParams() view returns (uint256, uint256)',
+ 'function getOwners() view returns (address[])',
+ 'function deposit(uint256 assets, address receiver) payable returns (uint256)',
+ 'function redeemWithSlippageProtection(uint256 shares, address receiver, address owner, uint256 minAssetsOut) returns (uint256)',
+])
+
+const reader = evmReader('monad')
+const shmon = SHMON as Address
+
+function addressIn(word: string | undefined): string {
+ return getAddress(`0x${(word ?? '').slice(-40)}`)
+}
+
+const TRANSFER = parseAbiItem(
+ 'event Transfer(address indexed from, address indexed to, uint256 value)'
+)
+
+let holder: Promise | undefined
+
+/** A plain account: a contract may refuse MON, which would say nothing about FastLane. */
+async function scanForHolder(): Promise {
+ const latest = await reader.getBlockNumber()
+ const seen = new Set()
+ for (let i = 0; i < WINDOWS_TO_SCAN; i++) {
+ const toBlock = latest - BigInt(i) * LOG_WINDOW
+ const logs = await reader.getLogs({
+ address: shmon,
+ event: TRANSFER,
+ fromBlock: toBlock - LOG_WINDOW + BigInt(1),
+ toBlock,
+ })
+ for (const { args } of logs) {
+ const to = args.to
+ if (to === undefined || to === zeroAddress || seen.has(to)) continue
+ seen.add(to)
+ const [code, shares, coin] = await Promise.all([
+ reader.getCode({ address: to }),
+ reader.readContract({ address: shmon, abi: ABI, functionName: 'balanceOf', args: [to] }),
+ reader.getBalance({ address: to }),
+ ])
+ if ((code === undefined || code === '0x') && shares >= ENOUGH && coin >= ENOUGH) return to
+ }
+ }
+ throw new Error('No plain account holding shMON and MON turned up in the logs scanned.')
+}
+
+const findHolder = (): Promise => (holder ??= scanForHolder())
+
+describe.skipIf(SKIP)('FastLane on Monad', () => {
+ it(
+ 'is still the token we pinned',
+ async () => {
+ const [symbol, decimals] = await Promise.all([
+ reader.readContract({ address: shmon, abi: ABI, functionName: 'symbol' }),
+ reader.readContract({ address: shmon, abi: ABI, functionName: 'decimals' }),
+ ])
+ expect(symbol).toBe('shMON')
+ expect(decimals).toBe(18)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'still runs the implementation we read',
+ async () => {
+ const word = await reader.getStorageAt({ address: shmon, slot: IMPLEMENTATION_SLOT })
+ expect(addressIn(word)).toBe(IMPLEMENTATION)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'still has the keys the card describes',
+ async () => {
+ const [admin, adminOwner, delay, owner, threshold, owners] = await Promise.all([
+ reader.getStorageAt({ address: shmon, slot: ADMIN_SLOT }),
+ reader.readContract({ address: PROXY_ADMIN, abi: ABI, functionName: 'owner' }),
+ reader.readContract({ address: TIMELOCK, abi: ABI, functionName: 'getMinDelay' }),
+ reader.readContract({ address: shmon, abi: ABI, functionName: 'owner' }),
+ reader.readContract({ address: OPERATIONS_SAFE, abi: ABI, functionName: 'getThreshold' }),
+ reader.readContract({ address: OPERATIONS_SAFE, abi: ABI, functionName: 'getOwners' }),
+ ])
+ expect(addressIn(admin)).toBe(PROXY_ADMIN)
+ expect(adminOwner).toBe(TIMELOCK)
+ expect(delay).toBe(BigInt(3 * 24 * 60 * 60))
+ expect(owner).toBe(OPERATIONS_SAFE)
+ expect(threshold).toBe(BigInt(5))
+ expect(owners).toHaveLength(7)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'still charges the instant exit the fee range the card states',
+ async () => {
+ const [slope, base] = await reader.readContract({
+ address: shmon,
+ abi: ABI,
+ functionName: 'getFeeCurveParams',
+ })
+ const ray = BigInt(10) ** BigInt(27)
+ expect(slope).toBe(ray / BigInt(100))
+ expect(base).toBe((ray * BigInt(5)) / BigInt(100_000))
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'mints in a dry run what the preview says, and refuses value that does not match',
+ async () => {
+ const account = await findHolder()
+ const amount = BigInt(10) ** BigInt(15)
+ const data = encodeFunctionData({
+ abi: ABI,
+ functionName: 'deposit',
+ args: [amount, account],
+ })
+ const [preview, dry] = await Promise.all([
+ reader.readContract({
+ address: shmon,
+ abi: ABI,
+ functionName: 'previewDeposit',
+ args: [amount],
+ }),
+ reader.call({ account, to: shmon, data, value: amount }),
+ ])
+ // The two reads can land a block apart, and the rate moves a little between blocks.
+ const gap = BigInt(dry.data ?? '0x0') - preview
+ expect(gap < BigInt(0) ? -gap : gap).toBeLessThan(preview / BigInt(100_000))
+ await expect(
+ reader.call({ account, to: shmon, data, value: amount - BigInt(1) })
+ ).rejects.toThrow()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'builds a stake, and passes its own check',
+ async () => {
+ const account = await findHolder()
+ const venue = createFastlaneVenue()
+ const prepared = await venue.prepare(
+ { kind: 'stake', amount: '0.001', tokenIn: 'MON' },
+ account
+ )
+ expect(() => venue.assertSafe(prepared.signing, account)).not.toThrow()
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'builds an unstake that dry-runs, and the chain refuses a minimum above what it pays',
+ async () => {
+ const account = await findHolder()
+ const venue = createFastlaneVenue()
+ const prepared = await venue.prepare(
+ { kind: 'unstake', amount: '0.01', tokenIn: 'shMON' },
+ account
+ )
+ expect(() => venue.assertSafe(prepared.signing, account)).not.toThrow()
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ const tooMuch = encodeFunctionData({
+ abi: ABI,
+ functionName: 'redeemWithSlippageProtection',
+ args: [BigInt(10) ** BigInt(16), account, account, BigInt(10) ** BigInt(18)],
+ })
+ await expect(reader.call({ account, to: shmon, data: tooMuch })).rejects.toThrow()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'values a shMON above a MON',
+ async () => {
+ const price = await shmonPriceUsd(1)
+ expect(price).toBeGreaterThan(1)
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/fastlane.test.ts b/apps/dapp/lib/__tests__/fastlane.test.ts
new file mode 100644
index 00000000..92d5cd18
--- /dev/null
+++ b/apps/dapp/lib/__tests__/fastlane.test.ts
@@ -0,0 +1,497 @@
+import { encodeFunctionData, erc20Abi, maxUint256, parseAbi, type Address } from 'viem'
+import { describe, expect, it, vi } from 'vitest'
+
+import type { EvmReaderClient } from '../chains/evm/call'
+import type { EvmClient, EvmReceipt } from '../chains/evm/client'
+import { createFastlaneVenue, FASTLANE_TOKENS, SHMON, shmonPriceUsd } from '../chains/evm/fastlane'
+import { EVM_CHAINS } from '../chains/evm/tokens'
+import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types'
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111' as Address
+const STRANGER = '0x2222222222222222222222222222222222222222' as Address
+const OTHER_CONTRACT = '0x4444444444444444444444444444444444444444'
+const USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603'
+const HASH = `0x${'a'.repeat(64)}`
+const MONAD_ID = EVM_CHAINS.monad.chain.id
+
+const E18 = BigInt(10) ** BigInt(18)
+const ONE = E18
+const TWO = BigInt(2) * E18
+
+const ABI = parseAbi([
+ 'function deposit(uint256 assets, address receiver) payable returns (uint256)',
+ 'function redeemWithSlippageProtection(uint256 shares, address receiver, address owner, uint256 minAssetsOut) returns (uint256)',
+ 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
+ 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)',
+ 'function mint(uint256 shares, address receiver) payable returns (uint256)',
+ 'function requestUnstake(uint256 shares) returns (uint64)',
+ 'function completeUnstake()',
+ 'function boostYield(uint256 shares, address from, address yieldOriginator)',
+])
+
+function deposit(receiver: Address = ACCOUNT, assets = ONE): string {
+ return encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [assets, receiver] })
+}
+
+function redeem(
+ receiver: Address = ACCOUNT,
+ owner: Address = ACCOUNT,
+ minOut = ONE,
+ shares = ONE
+): string {
+ return encodeFunctionData({
+ abi: ABI,
+ functionName: 'redeemWithSlippageProtection',
+ args: [shares, receiver, owner, minOut],
+ })
+}
+
+function stakePayload(overrides: Partial> = {}) {
+ return {
+ family: 'evm' as const,
+ chainId: MONAD_ID,
+ to: SHMON,
+ data: deposit(),
+ value: ONE.toString(),
+ ...overrides,
+ }
+}
+
+function unstakePayload(overrides: Partial> = {}) {
+ return stakePayload({ data: redeem(), value: '0', ...overrides })
+}
+
+interface Chain {
+ preview?: bigint | Error
+ shares?: bigint
+ redeemable?: bigint
+ fee?: bigint
+ net?: bigint
+ coin?: bigint | Error
+ rate?: bigint | Error
+ callFails?: boolean
+}
+
+function fakeReader(chain: Chain = {}) {
+ const call = vi.fn(async () => {
+ if (chain.callFails === true) throw new Error('reverted')
+ return { data: '0x' }
+ })
+ const reader = {
+ call,
+ getBalance: async () => {
+ const coin = chain.coin ?? BigInt(100) * E18
+ if (coin instanceof Error) throw coin
+ return coin
+ },
+ readContract: async ({ functionName }: { functionName: string }) => {
+ switch (functionName) {
+ case 'previewDeposit': {
+ const preview = chain.preview ?? (BigInt(6) * E18) / BigInt(10)
+ if (preview instanceof Error) throw preview
+ return preview
+ }
+ case 'balanceOf':
+ return chain.shares ?? TWO
+ case 'maxRedeem':
+ return chain.redeemable ?? TWO
+ case 'previewRedeemDetailed':
+ return [
+ BigInt(0),
+ chain.fee ?? BigInt(5) * BigInt(10) ** BigInt(15),
+ chain.net ?? (BigInt(16) * E18) / BigInt(10),
+ ]
+ case 'previewUnstake': {
+ const rate = chain.rate ?? (BigInt(165) * E18) / BigInt(100)
+ if (rate instanceof Error) throw rate
+ return rate
+ }
+ default:
+ throw new Error(`unexpected read of ${functionName}`)
+ }
+ },
+ } as unknown as EvmReaderClient
+ return { reader, call }
+}
+
+function fakeClient(
+ status: EvmReceipt['status'],
+ sent: { input: string; value: bigint }
+): EvmClient {
+ return {
+ tokenBalance: async () => BigInt(0),
+ nativeBalance: async () => BigInt(0),
+ waitForReceipt: async () =>
+ ({ status, from: ACCOUNT, to: SHMON, logs: [] }) as unknown as EvmReceipt,
+ transaction: async () => sent,
+ }
+}
+
+const venueWith = (chain: Chain = {}, client?: EvmClient) =>
+ createFastlaneVenue(
+ fakeReader(chain).reader,
+ client ?? fakeClient('success', { input: deposit(), value: ONE })
+ )
+
+const STAKE: ChainActionRequest = { kind: 'stake', amount: '1', tokenIn: 'MON' }
+const UNSTAKE: ChainActionRequest = { kind: 'unstake', amount: '1', tokenIn: 'shMON' }
+
+const evm = (payload: SigningPayload) => {
+ if (payload.family !== 'evm') throw new Error('Expected an EVM payload.')
+ return payload
+}
+
+describe('the FastLane policy', () => {
+ const venue = venueWith()
+
+ it('passes a stake and an unstake for this person', () => {
+ expect(() => venue.assertSafe(stakePayload(), ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(unstakePayload(), ACCOUNT)).not.toThrow()
+ })
+
+ const call = (functionName: string, args: readonly unknown[]): string =>
+ encodeFunctionData({ abi: ABI, functionName, args } as Parameters[0])
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', stakePayload({ chainId: 43114 })],
+ ['an unknown contract', stakePayload({ to: OTHER_CONTRACT })],
+ [
+ 'the shMON address on another function: redeem',
+ unstakePayload({ data: call('redeem', [ONE, ACCOUNT, ACCOUNT]) }),
+ ],
+ ['withdraw', unstakePayload({ data: call('withdraw', [ONE, ACCOUNT, ACCOUNT]) })],
+ ['mint', stakePayload({ data: call('mint', [ONE, ACCOUNT]) })],
+ ['the queue', unstakePayload({ data: call('requestUnstake', [ONE]) })],
+ ['a claim', unstakePayload({ data: call('completeUnstake', []) })],
+ ['a burn as a donation', unstakePayload({ data: call('boostYield', [ONE, ACCOUNT, ACCOUNT]) })],
+ [
+ 'a shMON transfer',
+ unstakePayload({
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [STRANGER, ONE],
+ }),
+ }),
+ ],
+ ['calldata that decodes as nothing', stakePayload({ data: '0xdeadbeef' })],
+ ['extra calldata on a stake', stakePayload({ data: `${deposit()}00` })],
+ ['extra calldata on an unstake', unstakePayload({ data: `${redeem()}00` })],
+ ['shMON credited to someone else', stakePayload({ data: deposit(STRANGER) })],
+ ['MON paid to someone else', unstakePayload({ data: redeem(STRANGER) })],
+ ['shMON burned from someone else', unstakePayload({ data: redeem(ACCOUNT, STRANGER) })],
+ ['value below the stake', stakePayload({ value: (ONE - BigInt(1)).toString() })],
+ ['value above the stake', stakePayload({ value: (ONE + BigInt(1)).toString() })],
+ ['a stake of nothing', stakePayload({ data: deposit(ACCOUNT, BigInt(0)), value: '0' })],
+ ['value on an unstake', unstakePayload({ value: '1' })],
+ ['an unstake with no minimum', unstakePayload({ data: redeem(ACCOUNT, ACCOUNT, BigInt(0)) })],
+ ['a value that is not digits', stakePayload({ value: '0x1' })],
+ [
+ 'an unlimited approval',
+ unstakePayload({
+ approval: {
+ to: USDC,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [SHMON as Address, maxUint256],
+ }),
+ },
+ }),
+ ],
+ [
+ 'an approval of the shMON itself',
+ unstakePayload({
+ approval: {
+ to: SHMON,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [SHMON as Address, ONE],
+ }),
+ },
+ }),
+ ],
+ ])('refuses %s', (_label, payload) => {
+ expect(() => venue.assertSafe(payload, ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it.each<[string, SigningPayload, RegExp]>([
+ ['the shMON', stakePayload({ data: deposit(STRANGER) }), /credits the shMON to someone else/],
+ ['the MON', unstakePayload({ data: redeem(STRANGER) }), /pays the MON to someone else/],
+ ['the burn', unstakePayload({ data: redeem(ACCOUNT, STRANGER) }), /belongs to someone else/],
+ ['the value', stakePayload({ value: '1' }), /different amount of MON/],
+ ['the unstake value', unstakePayload({ value: '1' }), /does not spend MON/],
+ ['the minimum', unstakePayload({ data: redeem(ACCOUNT, ACCOUNT, BigInt(0)) }), /no minimum/],
+ [
+ 'the function',
+ unstakePayload({ data: call('redeem', [ONE, ACCOUNT, ACCOUNT]) }),
+ /does something I haven't confirmed/,
+ ],
+ ['the contract', stakePayload({ to: OTHER_CONTRACT }), /contract I haven't confirmed/],
+ ['the extra data', stakePayload({ data: `${deposit()}00` }), /extra data/],
+ [
+ 'the approval',
+ unstakePayload({
+ approval: {
+ to: SHMON,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [SHMON as Address, ONE],
+ }),
+ },
+ }),
+ /approve a token I haven't confirmed/,
+ ],
+ ])('refuses %s for the reason it should', (_label, payload, reason) => {
+ expect(() => venue.assertSafe(payload, ACCOUNT)).toThrow(reason)
+ })
+
+ it('refuses the wrong account', () => {
+ expect(() => venue.assertSafe(stakePayload(), STRANGER)).toThrow(ChainActionRefused)
+ expect(() => venue.assertSafe(unstakePayload(), STRANGER)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('staking', () => {
+ it('builds a deposit of exactly the amount, for this person, with no approval', async () => {
+ const { reader, call } = fakeReader()
+ const venue = createFastlaneVenue(
+ reader,
+ fakeClient('success', { input: deposit(), value: ONE })
+ )
+ const prepared = await venue.prepare(STAKE, ACCOUNT)
+ const signing = evm(prepared.signing)
+ expect(signing.to).toBe(SHMON)
+ expect(signing.value).toBe(ONE.toString())
+ expect(signing.data).toBe(deposit())
+ expect(signing).not.toHaveProperty('approval')
+ expect(call).toHaveBeenCalledOnce()
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('shows what the preview read, and no rate', async () => {
+ const prepared = await venueWith().prepare(STAKE, ACCOUNT)
+ expect(prepared.receive).toEqual({ amount: '0.6', symbol: 'shMON', approximate: true })
+ expect(prepared.apy).toBeUndefined()
+ })
+
+ it('says how the person gets out, and who can change the contract', async () => {
+ const { notes } = await venueWith().prepare(STAKE, ACCOUNT)
+ const text = notes.join(' ')
+ expect(text).toContain('0.005% to 1.005%')
+ expect(text).toContain('4 to 5 epochs')
+ expect(text).toContain('3-day wait')
+ expect(text).toContain('5 of 7')
+ })
+
+ it('warns only when the stake would leave under the 10 MON reserve', async () => {
+ const reserve = /10 MON reserve/
+ const under = await venueWith({ coin: BigInt(10) * E18 }).prepare(STAKE, ACCOUNT)
+ expect(under.notes.join(' ')).toMatch(reserve)
+ expect(under.notes.join(' ')).toContain('this stake may fail')
+ expect(under.notes.join(' ')).not.toContain('this swap')
+ const over = await venueWith({ coin: BigInt(11) * E18 }).prepare(STAKE, ACCOUNT)
+ expect(over.notes.join(' ')).not.toMatch(reserve)
+ })
+
+ it.each<[string, ChainActionRequest, Chain]>([
+ ['another token', { ...STAKE, tokenIn: 'USDC' }, {}],
+ ['shMON', { ...STAKE, tokenIn: 'shMON' }, {}],
+ ['a zero amount', { ...STAKE, amount: '0' }, {}],
+ ['too many decimals', { ...STAKE, amount: '0.0000000000000000001' }, {}],
+ ['a preview that can not be read', STAKE, { preview: new Error('rpc') }],
+ ['a preview of nothing', STAKE, { preview: BigInt(0) }],
+ ['a balance that can not be read', STAKE, { coin: new Error('rpc') }],
+ ['a balance with no gas left over', STAKE, { coin: ONE }],
+ ['a call that would fail', STAKE, { callFails: true }],
+ ])('refuses %s', async (_label, request, chain) => {
+ await expect(venueWith(chain).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses a malformed account', async () => {
+ await expect(venueWith().prepare(STAKE, 'nope')).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('the race quote', () => {
+ it('reads the preview and tries nothing', async () => {
+ const { reader, call } = fakeReader()
+ const venue = createFastlaneVenue(
+ reader,
+ fakeClient('success', { input: deposit(), value: ONE })
+ )
+ await expect(venue.quote?.(STAKE, ACCOUNT)).resolves.toEqual({
+ receive: { amount: '0.6', symbol: 'shMON' },
+ })
+ expect(call).not.toHaveBeenCalled()
+ })
+
+ it('refuses when no preview can be read, rather than guessing', async () => {
+ await expect(venueWith({ preview: new Error('rpc') }).quote?.(STAKE, ACCOUNT)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+})
+
+describe('unstaking', () => {
+ it('builds the atomic exit with a minimum 1% under the preview, paying this person', async () => {
+ const venue = venueWith()
+ const prepared = await venue.prepare(UNSTAKE, ACCOUNT)
+ const signing = evm(prepared.signing)
+ const minOut = (BigInt(16) * E18 * BigInt(99)) / BigInt(1000)
+ expect(signing.data).toBe(redeem(ACCOUNT, ACCOUNT, minOut))
+ expect(signing.value).toBe('0')
+ expect(signing).not.toHaveProperty('approval')
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'shMON' })
+ expect(prepared.receive).toEqual({ amount: '1.6', symbol: 'MON', approximate: true })
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('states the fee it read, the range, and the queue it does not offer', async () => {
+ const { notes } = await venueWith().prepare(UNSTAKE, ACCOUNT)
+ const text = notes.join(' ')
+ expect(text).toContain('about 0.005 MON')
+ expect(text).toContain('0.005% to 1.005%')
+ expect(text).toContain('requestUnstake')
+ expect(text).toContain('does not offer')
+ })
+
+ it.each<[string, ChainActionRequest, Chain]>([
+ ['MON, which is what comes out', { ...UNSTAKE, tokenIn: 'MON' }, {}],
+ ['another token', { ...UNSTAKE, tokenIn: 'USDC' }, {}],
+ ['more than the person holds', UNSTAKE, { shares: ONE - BigInt(1) }],
+ ['more than the pool can cover', UNSTAKE, { redeemable: ONE - BigInt(1) }],
+ ['a preview of nothing', UNSTAKE, { net: BigInt(0) }],
+ ['a minimum of nothing', UNSTAKE, { net: BigInt(1) }],
+ ['no gas', UNSTAKE, { coin: BigInt(1) }],
+ ['a balance that can not be read', UNSTAKE, { coin: new Error('rpc') }],
+ ['a call that would fail', UNSTAKE, { callFails: true }],
+ ])('refuses %s', async (_label, request, chain) => {
+ await expect(venueWith(chain).prepare(request, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('names the free queue when the pool falls short', async () => {
+ await expect(venueWith({ redeemable: BigInt(0) }).prepare(UNSTAKE, ACCOUNT)).rejects.toThrow(
+ /queue with no fee/
+ )
+ })
+
+ describe('all of it', () => {
+ const WHOLE: ChainActionRequest = { ...UNSTAKE, wholePosition: true }
+
+ it('shows shMON in the wallet as staked, in shMON', async () => {
+ await expect(venueWith().positions?.(ACCOUNT)).resolves.toEqual([
+ { slot: 'staked', symbol: 'shMON', amount: '2' },
+ ])
+ await expect(venueWith({ shares: BigInt(0) }).positions?.(ACCOUNT)).resolves.toEqual([])
+ })
+
+ it('throws when it can not read, rather than saying nothing is there', async () => {
+ const failing = createFastlaneVenue(
+ {
+ readContract: async () => {
+ throw new Error('rpc')
+ },
+ } as unknown as EvmReaderClient,
+ fakeClient('success', { input: deposit(), value: ONE })
+ )
+ await expect(failing.positions?.(ACCOUNT)).rejects.toThrow()
+ })
+
+ it('exits every share the wallet holds now, whatever figure came in', async () => {
+ const venue = venueWith()
+ const prepared = await venue.prepare(WHOLE, ACCOUNT)
+ const minOut = (BigInt(16) * E18 * BigInt(99)) / BigInt(1000)
+ expect(evm(prepared.signing).data).toBe(redeem(ACCOUNT, ACCOUNT, minOut, TWO))
+ expect(prepared.pay).toEqual({ amount: '2', symbol: 'shMON' })
+ expect(prepared.summary).toMatch(/all of your shMON/)
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(prepared.signing, STRANGER)).toThrow(ChainActionRefused)
+ })
+
+ it.each<[string, Chain, RegExp]>([
+ ['nothing there', { shares: BigInt(0) }, /hold no shMON/],
+ ['less in the pool than the wallet holds', { redeemable: ONE }, /queue with no fee/],
+ ['no gas', { coin: BigInt(1) }, /network fee/],
+ ['a call that would fail', { callFails: true }, /would fail/],
+ ])('refuses it with %s', async (_label, chain, words) => {
+ const asked = venueWith(chain).prepare(WHOLE, ACCOUNT)
+ await expect(asked).rejects.toThrow(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ })
+ })
+
+ it('has nothing to quote, since an unstake does not race', async () => {
+ await expect(venueWith().quote?.(UNSTAKE, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('after the wallet sends', () => {
+ const signed = { family: 'evm' as const, hash: HASH }
+ const input = { account: ACCOUNT, venueId: 'fastlane', signed }
+
+ it('settles a landed stake', async () => {
+ const venue = venueWith({}, fakeClient('success', { input: deposit(), value: ONE }))
+ await expect(venue.submit?.(input)).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('names the reserve when a stake reverts', async () => {
+ const venue = venueWith({}, fakeClient('reverted', { input: deposit(), value: ONE }))
+ const result = await venue.submit?.(input)
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ expect(JSON.stringify(result)).toContain('10 MON reserve')
+ })
+
+ it('does not blame the reserve when an unstake reverts', async () => {
+ const venue = venueWith({}, fakeClient('reverted', { input: redeem(), value: BigInt(0) }))
+ const result = await venue.submit?.(input)
+ expect(result).toMatchObject({ ok: false })
+ expect(JSON.stringify(result)).not.toContain('reserve')
+ })
+
+ it('stays pending for a transaction that pays someone else', async () => {
+ const venue = venueWith(
+ {},
+ fakeClient('success', { input: redeem(STRANGER), value: BigInt(0) })
+ )
+ await expect(venue.submit?.(input)).resolves.toMatchObject({ ok: false, pending: true })
+ })
+})
+
+describe('following a swap', () => {
+ it('can stake MON and nothing else', async () => {
+ const venue = venueWith()
+ await expect(
+ venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'MON' }, ACCOUNT)
+ ).resolves.toBeUndefined()
+ await expect(
+ venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ACCOUNT)
+ ).rejects.toThrow(ChainActionRefused)
+ await expect(
+ venue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'MON' }, ACCOUNT)
+ ).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('shmonPriceUsd', () => {
+ it('values a shMON at what the contract says it is worth in MON', async () => {
+ await expect(shmonPriceUsd(2, fakeReader().reader)).resolves.toBeCloseTo(3.3)
+ })
+
+ it('gives no price when either side can not be read', async () => {
+ await expect(shmonPriceUsd(undefined, fakeReader().reader)).resolves.toBeUndefined()
+ await expect(
+ shmonPriceUsd(2, fakeReader({ rate: new Error('rpc') }).reader)
+ ).resolves.toBeUndefined()
+ })
+})
+
+describe('the shMON token', () => {
+ it('is the pinned contract', () => {
+ expect(FASTLANE_TOKENS).toEqual([{ symbol: 'shMON', id: SHMON, decimals: 18, name: 'ShMonad' }])
+ })
+})
diff --git a/apps/dapp/lib/__tests__/haedal.live.test.ts b/apps/dapp/lib/__tests__/haedal.live.test.ts
new file mode 100644
index 00000000..ae76899d
--- /dev/null
+++ b/apps/dapp/lib/__tests__/haedal.live.test.ts
@@ -0,0 +1,77 @@
+import { fromBase64 } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import {
+ HAEDAL_PACKAGE,
+ HAEDAL_STAKING,
+ haedalVenue,
+ HASUI_TYPE,
+ readObjectJson,
+ readU64,
+} from '../chains/sui/haedal'
+
+/**
+ * Pinned ids, and real unsigned transactions against mainnet, read-only. Nothing here signs or
+ * sends: each transaction is only built and dry-run, for public addresses whose keys nobody here
+ * has. When an address empties these fail on balance, so swap it for another funded one.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SUI_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const HASUI_HOLDER = '0x4d91d2c4fd130a651e42a2c3347217653eb84e87c4123ac4952a65292afca1fb'
+/** Haedal's UpgradeCap. Its `package` field is the latest package. */
+const UPGRADE_CAP = '0xc1c6690f21937f90e6b42f7c916d8e8c942926e3fb9e5c3c5584ae1e91502f25'
+
+async function simulateOk(transaction: string): Promise {
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(transaction),
+ include: { effects: true },
+ })
+ return result.Transaction?.effects?.status.success === true
+}
+
+describe.skipIf(SKIP)('Haedal against mainnet', () => {
+ it('builds a stake that passes its check and dry-runs clean', async () => {
+ const prepared = await haedalVenue.prepare(
+ { kind: 'stake', amount: '1', tokenIn: 'SUI' },
+ SUI_HOLDER
+ )
+ expect(prepared.receive?.symbol).toBe('haSUI')
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0.5)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => haedalVenue.assertSafe(prepared.signing, SUI_HOLDER)).not.toThrow()
+ expect(await simulateOk(prepared.signing.transaction)).toBe(true)
+ }, 60_000)
+
+ it('builds an instant unstake that pays the value it read, less only the fee it read', async () => {
+ const prepared = await haedalVenue.prepare(
+ { kind: 'unstake', amount: '1.5', tokenIn: 'haSUI' },
+ HASUI_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => haedalVenue.assertSafe(prepared.signing, HASUI_HOLDER)).not.toThrow()
+ expect(await simulateOk(prepared.signing.transaction)).toBe(true)
+
+ const value = await readU64(
+ HASUI_HOLDER,
+ `${HAEDAL_PACKAGE}::staking::get_sui_by_stsui`,
+ (tx) => [tx.object(HAEDAL_STAKING), tx.pure.u64(BigInt(1_500_000_000))]
+ )
+ const staking = await readObjectJson(HAEDAL_STAKING)
+ const config = staking?.['config'] as { service_fee: string }
+ const expected = (Number(value) * (1 - Number(config.service_fee) / 10_000_000)) / 1e9
+ expect(prepared.receive?.symbol).toBe('SUI')
+ expect(Math.abs(Number(prepared.receive?.amount) - expected)).toBeLessThan(0.00001)
+ }, 60_000)
+
+ it("still matches Haedal's upgrade cap and objects, or the ids need a fresh look", async () => {
+ const cap = await readObjectJson(UPGRADE_CAP)
+ expect(cap?.['package']).toBe(HAEDAL_PACKAGE)
+ const staking = await readObjectJson(HAEDAL_STAKING)
+ expect(staking?.['id']).toBe(HAEDAL_STAKING)
+ const { coinMetadata } = await suiClient().core.getCoinMetadata({ coinType: HASUI_TYPE })
+ expect(coinMetadata?.symbol).toBe('haSUI')
+ expect(coinMetadata?.decimals).toBe(9)
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/haedal.test.ts b/apps/dapp/lib/__tests__/haedal.test.ts
new file mode 100644
index 00000000..3f8692a3
--- /dev/null
+++ b/apps/dapp/lib/__tests__/haedal.test.ts
@@ -0,0 +1,403 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Inputs, Transaction } from '@mysten/sui/transactions'
+import { toBase64 } from '@mysten/sui/utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ assertPinnedObjects,
+ creditedAmount,
+ HAEDAL_PACKAGE,
+ HAEDAL_STAKING,
+ HAEDAL_TOKENS,
+ haedalVenue,
+ HASUI_TYPE,
+ percentText,
+ returnedCoinAmount,
+} from '../chains/sui/haedal'
+import { ChainActionRefused } from '../chains/types'
+
+const ME = '0x1111111111111111111111111111111111111111111111111111111111111111'
+const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead'
+const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01'
+const DIGEST = '11111111111111111111111111111111'
+
+const mocks = vi.hoisted(() => ({
+ simulate: vi.fn(),
+ getObjects: vi.fn(),
+ built: [] as Transaction[],
+ bytes: '',
+}))
+
+vi.mock('../chains/sui/client', async (original) => ({
+ ...(await original()),
+ suiClient: () => ({
+ core: { simulateTransaction: mocks.simulate, getObjects: mocks.getObjects },
+ }),
+ buildUnsigned: async (tx: Transaction) => {
+ mocks.built.push(tx)
+ return mocks.bytes
+ },
+}))
+
+function shared(tx: Transaction, objectId: string) {
+ return tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion: 1, mutable: true }))
+}
+
+function ownedCoin(tx: Transaction, objectId = FOREIGN) {
+ return tx.object(Inputs.ObjectRef({ objectId, version: 1, digest: DIGEST }))
+}
+
+async function built(
+ edit: (tx: Transaction) => void,
+ sender: string = ME,
+ gasOwner: string = sender
+): Promise {
+ const tx = new Transaction()
+ tx.setSender(sender)
+ tx.setGasOwner(gasOwner)
+ tx.setGasPrice(1000)
+ tx.setGasBudget(10_000_000)
+ tx.setGasPayment([{ objectId: FOREIGN, version: 1, digest: DIGEST }])
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+const STAKE = `${HAEDAL_PACKAGE}::staking::request_stake_coin`
+const UNSTAKE = `${HAEDAL_PACKAGE}::staking::request_unstake_instant_coin`
+
+function goodStake(tx: Transaction, validator = '0x0', staking = HAEDAL_STAKING, to = ME): void {
+ const [sui] = tx.splitCoins(tx.gas, [BigInt(1_000_000_000)])
+ const [minted] = tx.moveCall({
+ target: STAKE,
+ arguments: [shared(tx, '0x5'), shared(tx, staking), sui as never, tx.pure.address(validator)],
+ })
+ tx.transferObjects([minted as never], to)
+}
+
+function goodUnstake(tx: Transaction, to = ME): void {
+ const [sui] = tx.moveCall({
+ target: UNSTAKE,
+ arguments: [shared(tx, '0x5'), shared(tx, HAEDAL_STAKING), ownedCoin(tx, THIEF)],
+ })
+ tx.transferObjects([sui as never], to)
+}
+
+function check(transaction: string, account: string = ME): void {
+ haedalVenue.assertSafe({ family: 'sui', transaction }, account)
+}
+
+beforeEach(() => {
+ mocks.simulate.mockReset()
+ mocks.getObjects.mockReset()
+ mocks.built.length = 0
+ mocks.getObjects.mockResolvedValue({
+ objects: [{ json: { config: { service_fee: '2000', reward_fee: '600000' } } }],
+ })
+})
+
+describe('Haedal safety on what the wallet would sign', () => {
+ it('passes the stake and the unstake it builds', async () => {
+ await expect(built(goodStake).then(check)).resolves.toBeUndefined()
+ await expect(built(goodUnstake).then(check)).resolves.toBeUndefined()
+ })
+
+ it('refuses the wrong account', async () => {
+ const transaction = await built(goodStake)
+ expect(() => check(transaction, THIEF)).toThrow(/different account/)
+ })
+
+ it('refuses gas paid by someone else', async () => {
+ const transaction = await built(goodStake, ME, THIEF)
+ expect(() => check(transaction)).toThrow(/network fee/)
+ })
+
+ it('refuses an unknown contract', async () => {
+ const transaction = await built((tx) => {
+ goodStake(tx)
+ tx.moveCall({ target: `${FOREIGN}::drain::everything` })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it.each([
+ 'request_unstake_delay',
+ 'request_unstake_instant_v2',
+ 'claim_coin_v2',
+ 'get_exchange_rate',
+ ])('refuses %s, which this flow never uses', async (fn) => {
+ const transaction = await built((tx) => {
+ tx.moveCall({ target: `${HAEDAL_PACKAGE}::staking::${fn}` })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it('refuses anything in the vault and manage modules', async () => {
+ for (const target of ['vault::deposit', 'manage::collect_service_fee']) {
+ const transaction = await built((tx) => {
+ tx.moveCall({ target: `${HAEDAL_PACKAGE}::${target}` })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ }
+ })
+
+ it('refuses extra steps', async () => {
+ const transaction = await built((tx) => {
+ goodStake(tx)
+ tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] })
+ })
+ expect(() => check(transaction)).toThrow(/Publish/)
+ })
+
+ it('refuses the framework transfer, which would hide a payment', async () => {
+ const transaction = await built((tx) => {
+ goodStake(tx)
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target: '0x2::transfer::public_transfer',
+ typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'],
+ arguments: [coin as never, tx.pure.address(THIEF)],
+ })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it('refuses haSUI paid to someone else', async () => {
+ const transaction = await built((tx) => goodStake(tx, '0x0', HAEDAL_STAKING, THIEF))
+ expect(() => check(transaction)).toThrow(/another address/)
+ })
+
+ it('refuses SUI from an unstake paid to someone else', async () => {
+ const transaction = await built((tx) => goodUnstake(tx, THIEF))
+ expect(() => check(transaction)).toThrow(/another address/)
+ })
+
+ it('refuses a stake object other than Haedal’s', async () => {
+ const transaction = await built((tx) => goodStake(tx, '0x0', FOREIGN))
+ expect(() => check(transaction)).toThrow(/object/)
+ })
+
+ it('refuses a validator it did not choose', async () => {
+ const transaction = await built((tx) => goodStake(tx, THIEF))
+ expect(() => check(transaction)).toThrow(/validator/)
+ })
+
+ it('refuses a call that names a token', async () => {
+ const transaction = await built((tx) => {
+ tx.moveCall({
+ target: UNSTAKE,
+ typeArguments: ['0x2::sui::SUI'],
+ arguments: [shared(tx, '0x5'), shared(tx, HAEDAL_STAKING), ownedCoin(tx)],
+ })
+ })
+ expect(() => check(transaction)).toThrow(/token/)
+ })
+
+ it('refuses a payload that is not a Sui transaction', () => {
+ expect(() => haedalVenue.assertSafe({ family: 'solana', transaction: 'AA==' }, ME)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => check('not base64 at all!')).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a call with the wrong number of arguments', async () => {
+ const transaction = await built((tx) => {
+ tx.moveCall({ target: UNSTAKE, arguments: [shared(tx, '0x5')] })
+ })
+ expect(() => check(transaction)).toThrow(/in a way/)
+ })
+
+ it('leaves an unpinned call alone in assertPinnedObjects', async () => {
+ const transaction = await built(goodStake)
+ expect(() =>
+ assertPinnedObjects({ family: 'sui', transaction }, new Map(), 'Haedal')
+ ).not.toThrow()
+ })
+})
+
+describe('building a Haedal stake', () => {
+ it('refuses a token that is not SUI', async () => {
+ await expect(
+ haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'USDC' }, ME)
+ ).rejects.toThrow(/stakes SUI/)
+ expect(mocks.built).toHaveLength(0)
+ })
+
+ it('refuses under the contract’s minimum', async () => {
+ await expect(
+ haedalVenue.prepare({ kind: 'stake', amount: '0.5', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/at least 1 SUI/)
+ })
+
+ it('refuses a bad account before anything else', async () => {
+ await expect(
+ haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, 'nope')
+ ).rejects.toThrow(/Sui address/)
+ })
+
+ it('reads the minted haSUI from the dry run and shows the fee it read', async () => {
+ mocks.bytes = await built(goodStake)
+ mocks.simulate.mockResolvedValue({
+ Transaction: {
+ balanceChanges: [
+ { coinType: '0x2::sui::SUI', address: ME, amount: '-1000000000' },
+ { coinType: HASUI_TYPE, address: ME, amount: '924000000' },
+ ],
+ },
+ })
+ const prepared = await haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME)
+ expect(prepared.receive).toEqual({ amount: '0.924', symbol: 'haSUI', approximate: true })
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/keeps 6%/)
+ expect(notes).toMatch(/fee of 0.02%/)
+ expect(notes).toMatch(/\$915K in June 2026/)
+ expect(notes).toMatch(/could not confirm/)
+ const data = mocks.built[0]?.getData()
+ const targets = data?.commands.flatMap((c) =>
+ c.$kind === 'MoveCall' ? [c.MoveCall.function] : []
+ )
+ expect(targets).toEqual(['request_stake_coin'])
+ expect(prepared.apy).toBeUndefined()
+ })
+
+ it('refuses when the dry run credits no haSUI to this account', async () => {
+ mocks.bytes = await built(goodStake)
+ mocks.simulate.mockResolvedValue({
+ Transaction: { balanceChanges: [{ coinType: HASUI_TYPE, address: THIEF, amount: '5' }] },
+ })
+ await expect(
+ haedalVenue.prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/couldn't confirm/)
+ })
+
+ it('quotes the same reading, with no rate', async () => {
+ mocks.bytes = await built(goodStake)
+ mocks.simulate.mockResolvedValue({
+ Transaction: { balanceChanges: [{ coinType: HASUI_TYPE, address: ME, amount: '924000000' }] },
+ })
+ const quote = await haedalVenue.quote?.({ kind: 'stake', amount: '1', tokenIn: 'SUI' }, ME)
+ expect(quote).toEqual({ receive: { amount: '0.924', symbol: 'haSUI' } })
+ })
+})
+
+describe('building a Haedal unstake', () => {
+ const u64 = (n: string) => bcs.u64().serialize(n).toBytes()
+ const coin = (n: string) =>
+ bcs
+ .struct('Coin', { id: bcs.Address, value: bcs.u64() })
+ .serialize({ id: FOREIGN, value: n })
+ .toBytes()
+
+ function answers(value: string, payout: string | undefined): void {
+ mocks.simulate.mockImplementation(async ({ transaction }: { transaction: unknown }) =>
+ transaction instanceof Transaction
+ ? { Transaction: {}, commandResults: [{ returnValues: [{ bcs: u64(value) }] }] }
+ : {
+ Transaction: {},
+ commandResults: [{ returnValues: payout === undefined ? [] : [{ bcs: coin(payout) }] }],
+ }
+ )
+ }
+
+ it('refuses a token that is not haSUI', async () => {
+ await expect(
+ haedalVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'afSUI' }, ME)
+ ).rejects.toThrow(/unstakes haSUI/)
+ })
+
+ it('refuses under 1 SUI of value, as the contract would', async () => {
+ answers('999999999', '999799999')
+ await expect(
+ haedalVenue.prepare({ kind: 'unstake', amount: '0.9', tokenIn: 'haSUI' }, ME)
+ ).rejects.toThrow(/at least 1 SUI worth/)
+ expect(mocks.built).toHaveLength(0)
+ })
+
+ it('refuses when the value cannot be read', async () => {
+ mocks.simulate.mockResolvedValue({ Transaction: {}, commandResults: [] })
+ await expect(
+ haedalVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, ME)
+ ).rejects.toThrow(/couldn't read/)
+ })
+
+ it('reads the SUI from the coin the call returned, not the wallet’s balance change', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2162000000', '2161567600')
+ const prepared = await haedalVenue.prepare(
+ { kind: 'unstake', amount: '2', tokenIn: 'haSUI' },
+ ME
+ )
+ expect(prepared.kind).toBe('unstake')
+ expect(prepared.pay).toEqual({ amount: '2', symbol: 'haSUI' })
+ expect(prepared.receive).toEqual({ amount: '2.161567', symbol: 'SUI', approximate: true })
+ expect(prepared.notes.join(' ')).toMatch(/instant unstake/)
+ expect(prepared.notes.join(' ')).toMatch(/0.02%/)
+ expect(prepared.notes.join(' ')).toMatch(/separate claim/)
+ const targets = mocks.built[0]
+ ?.getData()
+ .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : []))
+ expect(targets).toEqual(['request_unstake_instant_coin'])
+ })
+
+ it('refuses when the dry run shows no payout', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2162000000', undefined)
+ await expect(
+ haedalVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, ME)
+ ).rejects.toThrow(/couldn't confirm/)
+ })
+
+ it('refuses when the fee cannot be read, rather than leaving it off the card', async () => {
+ mocks.bytes = await built(goodUnstake)
+ answers('2162000000', '2161567600')
+ mocks.getObjects.mockResolvedValue({ objects: [{ json: { config: {} } }] })
+ await expect(
+ haedalVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'haSUI' }, ME)
+ ).rejects.toThrow(/fees/)
+ })
+})
+
+describe('after a swap', () => {
+ it('stakes SUI only, and never unstakes', async () => {
+ await expect(
+ haedalVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SUI' }, ME)
+ ).resolves.toBeUndefined()
+ await expect(
+ haedalVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ME)
+ ).rejects.toThrow(/takes SUI/)
+ await expect(
+ haedalVenue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/only stake/)
+ })
+})
+
+describe('helpers', () => {
+ it('finds the coin a call returned by its place in the transaction', async () => {
+ const transaction = await built(goodUnstake)
+ const coinBytes = bcs
+ .struct('Coin', { id: bcs.Address, value: bcs.u64() })
+ .serialize({ id: FOREIGN, value: '7' })
+ .toBytes()
+ expect(returnedCoinAmount([{ returnValues: [{ bcs: coinBytes }] }], transaction, UNSTAKE)).toBe(
+ '7'
+ )
+ expect(returnedCoinAmount([], transaction, UNSTAKE)).toBeUndefined()
+ expect(returnedCoinAmount(undefined, transaction, STAKE)).toBeUndefined()
+ })
+
+ it('never reads a debit as a credit', () => {
+ expect(
+ creditedAmount([{ coinType: HASUI_TYPE, address: ME, amount: '-5' }], HASUI_TYPE, ME)
+ ).toBeUndefined()
+ })
+
+ it('writes small rates plainly', () => {
+ expect(percentText(0.02)).toBe('0.02')
+ expect(percentText(0.3365)).toBe('0.34')
+ expect(percentText(6)).toBe('6')
+ expect(percentText(0.001)).toBe('under 0.01')
+ })
+
+ it('lists haSUI with the decimals its metadata gives', () => {
+ expect(HAEDAL_TOKENS).toEqual([expect.objectContaining({ symbol: 'haSUI', decimals: 9 })])
+ })
+})
diff --git a/apps/dapp/lib/__tests__/jito.test.ts b/apps/dapp/lib/__tests__/jito.test.ts
new file mode 100644
index 00000000..cd0346b3
--- /dev/null
+++ b/apps/dapp/lib/__tests__/jito.test.ts
@@ -0,0 +1,406 @@
+import { describe, expect, it } from 'vitest'
+
+import { associatedTokenAccount } from '../chains/solana/assert-safe'
+import { createJitoVenue, JITO_STAKE_POOL, JITOSOL_MINT } from '../chains/solana/jito'
+import { jitoListing } from '../chains/solana/jito-listing'
+import {
+ computeBudget,
+ decodeStakePool,
+ readU64,
+ STAKE_POOL_PROGRAM,
+} from '../chains/solana/stake-pool'
+import { decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import type { PreparedAction } from '../chains/types'
+import {
+ budget,
+ encodeStakePool,
+ fakeRpc,
+ ME,
+ payload,
+ rebuilt,
+ SOMEONE_ELSE,
+ transferOf,
+ UNKNOWN_PROGRAM,
+ unlimitedApproval,
+ withAccount,
+ withExtraData,
+ withOtherFunction,
+ type PoolFixture,
+} from './solana-stake-fixtures'
+
+const RESERVE = 'BgKUXdS29YcHCFrPm5M8oLHiTzZaMDjsebggjoaQ6KFL'
+const EPOCH = 1077
+
+const BASE: PoolFixture = {
+ poolMint: JITOSOL_MINT,
+ reserveStake: RESERVE,
+ managerFeeAccount: '8yoigZfzZ1nNaadumY9uPVD118225UYHTDpmjpr2nrSa',
+ validatorList: '3R3nGZpQs2aZo5FDQvd2MUQ6R7KhAPainds6uT6uE2mn',
+ totalLamports: BigInt('10351794116510279'),
+ poolTokenSupply: BigInt('7903028498840376'),
+ lastUpdateEpoch: BigInt(EPOCH),
+ solWithdrawalFee: [BigInt(1), BigInt(1000)],
+}
+
+function venueWith(
+ overrides: Partial = {},
+ reserveLamports = BigInt('40298249972443')
+) {
+ const rpc = fakeRpc({
+ epoch: EPOCH,
+ accounts: {
+ [JITO_STAKE_POOL]: {
+ owner: STAKE_POOL_PROGRAM,
+ lamports: BigInt(1),
+ data: encodeStakePool({ ...BASE, ...overrides }),
+ },
+ [RESERVE]: {
+ owner: 'Stake11111111111111111111111111111111111111',
+ lamports: reserveLamports,
+ data: new Uint8Array(0),
+ },
+ },
+ })
+ return { venue: createJitoVenue({ rpc }), rpc }
+}
+
+const stake = { kind: 'stake', amount: '1', tokenIn: 'SOL' } as const
+const unstake = { kind: 'unstake', amount: '1', tokenIn: 'JitoSOL' } as const
+
+async function preparedStake(): Promise {
+ return venueWith().venue.prepare(stake, ME)
+}
+
+async function preparedUnstake(): Promise {
+ return venueWith().venue.prepare(unstake, ME)
+}
+
+function refuses(action: () => unknown, pattern: RegExp): void {
+ expect(action).toThrow(pattern)
+}
+
+describe('jito stake', () => {
+ it('reads the amount out from the pool, with no rate invented', async () => {
+ const prepared = await preparedStake()
+ // 1 SOL * supply / total, at the figures above.
+ expect(prepared.receive).toEqual({
+ amount: '0.763445293',
+ symbol: 'JitoSOL',
+ approximate: true,
+ })
+ expect(prepared.apy).toBeUndefined()
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'SOL' })
+ expect(prepared.notes.join(' ')).toMatch(/not showing a yearly rate/)
+ })
+
+ it('binds the deposit to the amount, a floor within 1%, and the person', async () => {
+ const prepared = await preparedStake()
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ expect(decoded.feePayer).toBe(ME)
+ expect(decoded.signerCount).toBe(1)
+ expect(decoded.feePayerSignature).toBeNull()
+ const deposit = decoded.instructions.at(-1)!
+ expect(decoded.staticAccounts[deposit.programIndex]).toBe(STAKE_POOL_PROGRAM)
+ expect(deposit.data[0]).toBe(25)
+ expect(readU64(deposit.data, 1)).toBe(BigInt('1000000000'))
+ const floor = readU64(deposit.data, 9)
+ expect(floor).toBeGreaterThanOrEqual((BigInt('763445293') * BigInt(99)) / BigInt(100))
+ expect(floor).toBeLessThan(BigInt('763445293'))
+ const own = associatedTokenAccount(ME, JITOSOL_MINT)
+ expect(decoded.staticAccounts[deposit.accounts[3]!]).toBe(ME)
+ expect(decoded.staticAccounts[deposit.accounts[4]!]).toBe(own)
+ expect(decoded.staticAccounts[deposit.accounts[6]!]).toBe(own)
+ })
+
+ it('passes its own check, and a quote builds nothing', async () => {
+ const { venue, rpc } = venueWith()
+ const prepared = await venue.prepare(stake, ME)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+
+ const quoting = venueWith()
+ await expect(quoting.venue.quote?.(stake, ME)).resolves.toEqual({
+ receive: { amount: '0.763445293', symbol: 'JitoSOL' },
+ })
+ expect(quoting.rpc.calls).not.toContain('getLatestBlockhash')
+ expect(rpc.calls).toContain('getLatestBlockhash')
+ })
+
+ it('refuses another token, an account that is no address, and a request it does not do', async () => {
+ const { venue } = venueWith()
+ await expect(venue.prepare({ ...stake, tokenIn: 'USDC' }, ME)).rejects.toThrow(/takes SOL/)
+ await expect(venue.prepare(stake, 'not-an-address')).rejects.toThrow(/not a Solana wallet/)
+ await expect(venue.prepare({ ...stake, kind: 'supply' }, ME)).rejects.toThrow(
+ /stake and unstake/
+ )
+ await expect(venue.prepare({ ...stake, amount: '0' }, ME)).rejects.toThrow(/more than zero/)
+ })
+
+ it('refuses a pool that wants another signature, is behind the epoch, or has another mint', async () => {
+ await expect(
+ venueWith({ solDepositAuthority: SOMEONE_ELSE }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/another signature/)
+ await expect(
+ venueWith({ lastUpdateEpoch: BigInt(EPOCH - 1) }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/still updating/)
+ await expect(
+ venueWith({ poolMint: 'So11111111111111111111111111111111111111112' }).venue.prepare(
+ stake,
+ ME
+ )
+ ).rejects.toThrow(/doesn’t look as expected/)
+ })
+
+ it('takes only SOL after a swap', async () => {
+ const { venue } = venueWith()
+ await expect(
+ venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SOL' }, ME)
+ ).resolves.toBeUndefined()
+ await expect(venue.assertCanFollow?.({ kind: 'stake', tokenIn: 'USDC' }, ME)).rejects.toThrow()
+ })
+})
+
+describe('jito unstake', () => {
+ it('pays the person from the reserve, after the fee read on chain', async () => {
+ const prepared = await preparedUnstake()
+ // 0.999 JitoSOL burned at total / supply: the fee rounds up, in the pool's favour.
+ expect(prepared.receive).toEqual({ amount: '1.308541696', symbol: 'SOL', approximate: true })
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'JitoSOL' })
+ expect(prepared.notes.join(' ')).toMatch(/0\.1%/)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ const withdraw = decoded.instructions.at(-1)!
+ expect(withdraw.data[0]).toBe(26)
+ expect(readU64(withdraw.data, 1)).toBe(BigInt('1000000000'))
+ expect(decoded.staticAccounts[withdraw.accounts[5]!]).toBe(ME)
+ })
+
+ it('refuses when the reserve is too thin, and says what else can be done', async () => {
+ await expect(venueWith({}, BigInt(1_000_000_000)).venue.prepare(unstake, ME)).rejects.toThrow(
+ /swap JitoSOL for SOL on Jupiter/
+ )
+ })
+
+ it('refuses an unstake named in SOL rather than JitoSOL', async () => {
+ await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'SOL' }, ME)).rejects.toThrow(
+ /takes JitoSOL/
+ )
+ })
+
+ it('refuses a pool that wants another signature to withdraw', async () => {
+ await expect(
+ venueWith({ solWithdrawAuthority: SOMEONE_ELSE }).venue.prepare(unstake, ME)
+ ).rejects.toThrow(/another signature/)
+ })
+})
+
+describe('jito unstake of everything', () => {
+ it('burns exactly the balance read, so nothing is left behind, and pays only the person', async () => {
+ const prepared = await venueWith().venue.prepare(
+ { ...unstake, amount: '151.268371658', wholePosition: true },
+ ME
+ )
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ const burn = decoded.instructions.at(-1)!
+ expect(readU64(burn.data, 1)).toBe(BigInt('151268371658'))
+ expect(decoded.staticAccounts[burn.accounts[5]!]).toBe(ME)
+ expect(() => venueWith().venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+})
+
+describe('jito assertSafe', () => {
+ const venue = venueWith().venue
+
+ it('refuses the wrong signer', async () => {
+ const prepared = await preparedStake()
+ refuses(() => venue.assertSafe(prepared.signing, SOMEONE_ELSE), /network fee/)
+ refuses(() => venue.assertSafe({ family: 'sui', transaction: 'AA==' }, ME), /not a Solana/)
+ })
+
+ it('refuses a transaction someone else pays for or must also sign', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rest), ME), /network fee/)
+ })
+
+ it('refuses an unknown program', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ const last = rest.at(-1)!
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ { ...last, programAddress: UNKNOWN_PROGRAM as never },
+ ]),
+ ME
+ ),
+ /should not need/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest, { ...last, programAddress: UNKNOWN_PROGRAM as never }]),
+ ME
+ ),
+ /should not need/
+ )
+ })
+
+ it('refuses an unknown function of a known program', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ refuses(
+ () =>
+ venue.assertSafe(payload(ME, [...rest.slice(0, -1), withOtherFunction(rest.at(-1)!)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses extra calldata', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(rest.at(-1)!)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses minted tokens going to someone else, on either token account', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ const theirs = associatedTokenAccount(SOMEONE_ELSE, JITOSOL_MINT)
+ for (const position of [4, 6]) {
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, position, theirs)]),
+ ME
+ ),
+ /isn’t what Jito should get/
+ )
+ }
+ })
+
+ it('refuses unstaked SOL going to someone else', async () => {
+ const prepared = await preparedUnstake()
+ const rest = rebuilt(prepared, ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 5, SOMEONE_ELSE)]),
+ ME
+ ),
+ /isn’t what Jito should get/
+ )
+ })
+
+ it('refuses a SOL transfer added to the transaction', async () => {
+ const prepared = await preparedStake()
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rebuilt(prepared, ME), transferOf(ME, SOMEONE_ELSE, BigInt(1))]),
+ ME
+ ),
+ /SOL transfer/
+ )
+ })
+
+ it('refuses an approval, even of the person’s own token', async () => {
+ const prepared = await preparedUnstake()
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rebuilt(prepared, ME),
+ unlimitedApproval(associatedTokenAccount(ME, JITOSOL_MINT), SOMEONE_ELSE, ME),
+ ]),
+ ME
+ ),
+ /moves or approves tokens/
+ )
+ })
+
+ it('refuses the wrong token and the wrong pool', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ const usdc = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 7, usdc)]),
+ ME
+ ),
+ /isn’t what Jito should get/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ withAccount(rest.at(-1)!, 0, 'po1osKDWYF9oiVEGmzKA4eTs8eMveFRMox3bUKazGN2'),
+ ]),
+ ME
+ ),
+ /isn’t what Jito should get/
+ )
+ })
+
+ it('refuses a floor of zero, which would leave no price protection', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME)
+ const last = rest.at(-1)!
+ const data = Uint8Array.from(last.data ?? [])
+ data.fill(0, 9)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME),
+ /isn’t what Jito should get/
+ )
+ })
+
+ it('refuses a huge priority fee and a missing budget', async () => {
+ const prepared = await preparedStake()
+ const rest = rebuilt(prepared, ME).slice(2)
+ refuses(() => venue.assertSafe(payload(ME, rest), ME), /compute budget/)
+ const price = new Uint8Array(9)
+ price[0] = 3
+ price.fill(255, 1)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ computeBudget(200_000)[0]!,
+ { programAddress: budget[1]!.programAddress, data: price },
+ ...rest,
+ ]),
+ ME
+ ),
+ /priority fee/
+ )
+ })
+
+ it('refuses unreadable bytes', () => {
+ refuses(
+ () => venue.assertSafe({ family: 'solana', transaction: '%%%' }, ME),
+ /could not be read/
+ )
+ })
+})
+
+describe('jito pins and listing', () => {
+ it('decodes the pool layout it encodes, reading fees apart', () => {
+ const state = decodeStakePool(encodeStakePool(BASE))
+ expect(state?.poolMint).toBe(JITOSOL_MINT)
+ expect(state?.solWithdrawalFee).toEqual({ numerator: BigInt(1), denominator: BigInt(1000) })
+ expect(state?.epochFee).toEqual({ numerator: BigInt(4), denominator: BigInt(100) })
+ expect(decodeStakePool(Uint8Array.of(2))).toBeUndefined()
+ })
+
+ it('lists as executing on Solana', () => {
+ expect(jitoListing).toMatchObject({ id: 'jito', family: 'solana', integration: 'executes' })
+ expect(jitoListing.capability).toMatch(/No yearly rate/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/kamino-lend.live.test.ts b/apps/dapp/lib/__tests__/kamino-lend.live.test.ts
new file mode 100644
index 00000000..62371e82
--- /dev/null
+++ b/apps/dapp/lib/__tests__/kamino-lend.live.test.ts
@@ -0,0 +1,409 @@
+import { z } from 'zod'
+import { describe, expect, it } from 'vitest'
+
+import { bytesFromBase64 } from '../chains/base64'
+import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import { createKaminoLendVenue } from '../chains/solana/kamino-lend'
+import {
+ decodeObligation,
+ decodeReserve,
+ KAMINO_LEND_RESERVES,
+ KAMINO_MAIN_MARKET,
+ KLEND_PROGRAM,
+ obligationAddress,
+ reserveListing,
+} from '../chains/solana/kamino-lend-rules'
+import { readOnlyRpc, readU64 } from '../chains/solana/stake-pool'
+import type { ChainActionRequest, PreparedAction } from '../chains/types'
+
+/**
+ * Read-only checks that the pins hold on chain, and that what this venue builds runs, by the
+ * network's simulation against real positions. `simulateTransaction` here has signature checks off,
+ * and nothing is ever signed or sent. The wallets are public and were found on chain just now.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 300_000
+const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+const NOTHING = '11111111111111111111111111111111'
+const USDC = reserveListing('USDC')
+const SOL = reserveListing('SOL')
+const env = {
+ ...process.env,
+ SOLANA_RPC_URL: process.env['SOLANA_RPC_URL'] ?? 'https://api.mainnet-beta.solana.com',
+}
+const baseRpc = readOnlyRpc(env)
+
+const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms))
+
+/** The public node allows only so much, and a refused read proves nothing about what's pinned. */
+const rpc = async (method: string, params: unknown[]): Promise => {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ await wait(400)
+ return await baseRpc(method, params)
+ } catch (e) {
+ if (attempt >= 4) throw e
+ await wait(6_000)
+ }
+ }
+}
+
+const venue = createKaminoLendVenue({ rpc })
+
+const Simulated = z.object({
+ value: z.object({
+ err: z.unknown().nullable(),
+ logs: z.array(z.string()).nullable(),
+ unitsConsumed: z.number().optional(),
+ accounts: z
+ .array(z.object({ lamports: z.number(), data: z.tuple([z.string(), z.string()]) }).nullable())
+ .nullable()
+ .optional(),
+ }),
+})
+
+async function simulate(prepared: PreparedAction, addresses: string[]) {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ expect(decodeSolanaTransaction(prepared.signing.transaction).feePayerSignature).toBeNull()
+ const reply = await rpc('simulateTransaction', [
+ prepared.signing.transaction,
+ {
+ encoding: 'base64',
+ sigVerify: false,
+ replaceRecentBlockhash: true,
+ commitment: 'confirmed',
+ accounts: { addresses, encoding: 'base64' },
+ },
+ ])
+ const { value } = Simulated.parse(reply)
+ console.info(
+ prepared.kind,
+ prepared.pay.amount,
+ prepared.pay.symbol,
+ 'units',
+ value.unitsConsumed,
+ 'bytes',
+ bytesFromBase64(prepared.signing.transaction).length
+ )
+ if (value.err !== null) console.error(JSON.stringify(value.logs?.slice(-14), null, 1))
+ expect(value.err).toBeNull()
+ return value
+}
+
+const tokenAmountAfter = (account: { data: [string, string] } | null | undefined): bigint =>
+ account === null || account === undefined
+ ? BigInt(0)
+ : readU64(bytesFromBase64(account.data[0]), 64)
+
+const Listed = z.array(
+ z.object({
+ pubkey: z.string(),
+ account: z.object({ data: z.tuple([z.string(), z.string()]) }),
+ })
+)
+
+const Accounts = z.object({
+ value: z.array(
+ z.object({ data: z.tuple([z.string(), z.string()]), lamports: z.number() }).nullable()
+ ),
+})
+
+interface Found {
+ owner: string
+ usdcInWallet: bigint
+ lamports: bigint
+}
+
+/**
+ * Real positions in this market, by their first deposit and first borrow slots, with what each
+ * owner's wallet holds. Only the plain position Kamino's app opens, which is the one this venue reads.
+ */
+async function positionsWith(deposit: string, firstBorrow: string): Promise {
+ const listed = Listed.parse(
+ await rpc('getProgramAccounts', [
+ KLEND_PROGRAM,
+ {
+ encoding: 'base64',
+ dataSlice: { offset: 64, length: 32 },
+ filters: [
+ { dataSize: 3344 },
+ { memcmp: { offset: 32, bytes: KAMINO_MAIN_MARKET } },
+ { memcmp: { offset: 96, bytes: deposit } },
+ { memcmp: { offset: 1208, bytes: firstBorrow } },
+ ],
+ },
+ ])
+ )
+ const found: Found[] = []
+ const owners = listed
+ .map(({ pubkey, account }) => ({
+ pubkey,
+ owner: getAddress(bytesFromBase64(account.data[0])),
+ }))
+ .filter(({ pubkey, owner }) => obligationAddress(owner) === pubkey)
+ .slice(0, 900)
+ for (let i = 0; i < owners.length; i += 90) {
+ const page = owners.slice(i, i + 90)
+ const wallets = Accounts.parse(
+ await rpc('getMultipleAccounts', [
+ page.map(({ owner }) => owner),
+ { encoding: 'base64', dataSlice: { offset: 0, length: 0 } },
+ ])
+ )
+ const tokens = Accounts.parse(
+ await rpc('getMultipleAccounts', [
+ page.map(({ owner }) => associatedTokenAccount(owner, USDC.mint)),
+ { encoding: 'base64', dataSlice: { offset: 64, length: 8 } },
+ ])
+ )
+ page.forEach(({ owner }, at) => {
+ const token = tokens.value[at]
+ const wallet = wallets.value[at]
+ if (wallet === null || wallet === undefined) return
+ found.push({
+ owner,
+ lamports: BigInt(wallet.lamports),
+ usdcInWallet:
+ token === null || token === undefined
+ ? BigInt(0)
+ : readU64(bytesFromBase64(token.data[0]), 0),
+ })
+ })
+ }
+ return found
+}
+
+function getAddress(bytes: Uint8Array): string {
+ const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+ let value = BigInt(0)
+ for (const byte of bytes) value = value * BigInt(256) + BigInt(byte)
+ let text = ''
+ while (value > BigInt(0)) {
+ text = alphabet[Number(value % BigInt(58))] + text
+ value /= BigInt(58)
+ }
+ for (const byte of bytes) {
+ if (byte !== 0) break
+ text = `1${text}`
+ }
+ return text
+}
+
+/** The first of these owners a request prepares for, trying others when a position can't. */
+async function firstThatPrepares(
+ owners: readonly Found[],
+ request: (owner: Found) => Promise
+): Promise<{ owner: string; prepared: PreparedAction }> {
+ const reasons: string[] = []
+ for (const owner of owners.slice(0, 40)) {
+ const asked = await request(owner)
+ if (asked === undefined) continue
+ try {
+ return { owner: owner.owner, prepared: await venue.prepare(asked, owner.owner) }
+ } catch (e) {
+ reasons.push(e instanceof Error ? e.message : String(e))
+ }
+ }
+ throw new Error(`Nothing prepared. ${[...new Set(reasons)].slice(0, 6).join(' | ')}`)
+}
+
+describe.skipIf(SKIP)('Kamino Lend pins on chain', () => {
+ it(
+ 'the market and every reserve are the program’s, for the token pinned',
+ async () => {
+ const market = Accounts.parse(
+ await rpc('getMultipleAccounts', [
+ [KAMINO_MAIN_MARKET, ...KAMINO_LEND_RESERVES.map((r) => r.reserve)],
+ { encoding: 'base64' },
+ ])
+ )
+ expect(market.value.every((account) => account !== null)).toBe(true)
+ for (const [i, listing] of KAMINO_LEND_RESERVES.entries()) {
+ const reserve = decodeReserve(bytesFromBase64(market.value[i + 1]?.data[0] as string))
+ expect(reserve?.market).toBe(KAMINO_MAIN_MARKET)
+ expect(reserve?.mint).toBe(listing.mint)
+ expect(reserve?.decimals).toBe(listing.decimals)
+ expect(reserve?.status).toBe(0)
+ console.info(listing.symbol, {
+ ltv: reserve?.ltvPct,
+ liquidation: reserve?.liquidationPct,
+ weight: reserve?.borrowFactorPct,
+ borrowLimit: reserve?.borrowLimit,
+ })
+ }
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('what Kamino Lend builds runs on chain', () => {
+ it(
+ 'a first SOL deposit opens the position and lands in it',
+ async () => {
+ const prepared = await venue.prepare(
+ { kind: 'supply', amount: '0.05', tokenIn: 'SOL' },
+ FUNDED
+ )
+ expect(() => venue.assertSafe(prepared.signing, FUNDED)).not.toThrow()
+ const obligation = obligationAddress(FUNDED)
+ const result = await simulate(prepared, [obligation])
+ const after = decodeObligation(bytesFromBase64(result.accounts?.[0]?.data[0] as string))
+ expect(after?.owner).toBe(FUNDED)
+ expect(after?.deposits[0]?.reserve).toBe(SOL.reserve)
+ expect(after?.deposits[0]?.cTokens).toBeGreaterThan(BigInt(0))
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a deposit by a real USDC holder leaves their wallet for their position',
+ async () => {
+ const owners = await positionsWith(USDC.reserve, NOTHING)
+ const { owner, prepared } = await firstThatPrepares(
+ owners.filter(
+ (o) => o.usdcInWallet >= BigInt(2_000_000) && o.lamports > BigInt(10_000_000)
+ ),
+ async () => ({ kind: 'supply', amount: '1', tokenIn: 'USDC' })
+ )
+ expect(() => venue.assertSafe(prepared.signing, owner)).not.toThrow()
+ const own = associatedTokenAccount(owner, USDC.mint)
+ const before = Accounts.parse(
+ await rpc('getMultipleAccounts', [[own], { encoding: 'base64' }])
+ ).value[0]
+ const result = await simulate(prepared, [own])
+ expect(tokenAmountAfter(before)! - tokenAmountAfter(result.accounts?.[0])).toBe(
+ BigInt(1_000_000)
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a real position borrows into its own wallet, and is told no past its limit',
+ async () => {
+ const owners = await positionsWith(SOL.reserve, USDC.reserve)
+ const { owner, prepared } = await firstThatPrepares(
+ owners.filter((o) => o.lamports > BigInt(10_000_000)),
+ async () => ({ kind: 'borrow', amount: '1', tokenIn: 'USDC' })
+ )
+ expect(() => venue.assertSafe(prepared.signing, owner)).not.toThrow()
+ console.info(prepared.notes.slice(0, 2))
+ const own = associatedTokenAccount(owner, USDC.mint)
+ const before = Accounts.parse(
+ await rpc('getMultipleAccounts', [[own], { encoding: 'base64' }])
+ ).value[0]
+ const result = await simulate(prepared, [own])
+ expect(tokenAmountAfter(result.accounts?.[0]) - tokenAmountAfter(before)).toBe(
+ BigInt(1_000_000)
+ )
+ await expect(
+ venue.prepare({ kind: 'borrow', amount: '500000000', tokenIn: 'USDC' }, owner)
+ ).rejects.toThrow(/can support|ready to lend/)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a real debt is paid down from its owner’s wallet',
+ async () => {
+ const owners = await positionsWith(SOL.reserve, USDC.reserve)
+ const { owner, prepared } = await firstThatPrepares(
+ owners.filter((o) => o.usdcInWallet >= BigInt(1_000_000) && o.lamports > BigInt(5_000_000)),
+ async () => ({ kind: 'repay', amount: '0.5', tokenIn: 'USDC' })
+ )
+ expect(() => venue.assertSafe(prepared.signing, owner)).not.toThrow()
+ const obligation = obligationAddress(owner)
+ const before = await venue.positions?.(owner)
+ const result = await simulate(prepared, [obligation])
+ const after = decodeObligation(bytesFromBase64(result.accounts?.[0]?.data[0] as string))
+ const owedBefore = Number(
+ before?.find((p) => p.slot === 'borrowed' && p.symbol === 'USDC')?.amount
+ )
+ const afterDebt = Number((after?.borrows[0]?.borrowedSf ?? BigInt(0)) >> BigInt(60)) / 1e6
+ expect(owedBefore - afterDebt).toBeGreaterThan(0.49)
+ expect(owedBefore - afterDebt).toBeLessThan(0.51)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a whole repay leaves no debt at all, interest included',
+ async () => {
+ const owners = await positionsWith(SOL.reserve, USDC.reserve)
+ const reader = createKaminoLendVenue({ rpc })
+ const { owner, prepared } = await firstThatPrepares(
+ owners.filter((o) => o.usdcInWallet > BigInt(0) && o.lamports > BigInt(5_000_000)),
+ async (candidate) => {
+ const positions = await reader.positions?.(candidate.owner)
+ const owed = positions?.find((p) => p.slot === 'borrowed' && p.symbol === 'USDC')?.amount
+ const debt = BigInt(Math.ceil(Number(owed ?? '0') * 1_000_000))
+ return debt > BigInt(0) && candidate.usdcInWallet > debt
+ ? { kind: 'repay', amount: owed as string, tokenIn: 'USDC', wholePosition: true }
+ : undefined
+ }
+ )
+ expect(() => venue.assertSafe(prepared.signing, owner)).not.toThrow()
+ const result = await simulate(prepared, [obligationAddress(owner)])
+ const after = decodeObligation(bytesFromBase64(result.accounts?.[0]?.data[0] as string))
+ expect(after?.borrows.filter((b) => b.reserve === USDC.reserve)).toEqual([])
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a real position with a loan takes a little out, into its own wallet',
+ async () => {
+ const owners = await positionsWith(SOL.reserve, USDC.reserve)
+ const { owner, prepared } = await firstThatPrepares(
+ owners.filter((o) => o.lamports > BigInt(5_000_000)),
+ async () => ({ kind: 'withdraw', amount: '0.001', tokenIn: 'SOL' })
+ )
+ expect(() => venue.assertSafe(prepared.signing, owner)).not.toThrow()
+ const result = await simulate(prepared, [owner])
+ // Wrapped and unwrapped in one go, so it shows as SOL in the wallet.
+ expect(result.accounts?.[0]?.lamports).toBeDefined()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a whole withdraw with no loan empties and closes the position',
+ async () => {
+ const owners = await positionsWith(USDC.reserve, NOTHING)
+ const reader = createKaminoLendVenue({ rpc })
+ const { owner, prepared } = await firstThatPrepares(
+ owners.filter((o) => o.lamports > BigInt(5_000_000)),
+ async (candidate) => {
+ const positions = await reader.positions?.(candidate.owner)
+ const usdc = positions?.filter((p) => p.slot === 'supplied')
+ return usdc?.length === 1 && usdc[0]?.symbol === 'USDC' && Number(usdc[0].amount) > 0
+ ? {
+ kind: 'withdraw',
+ amount: usdc[0].amount,
+ tokenIn: 'USDC',
+ wholePosition: true,
+ }
+ : undefined
+ }
+ )
+ expect(() => venue.assertSafe(prepared.signing, owner)).not.toThrow()
+ const result = await simulate(prepared, [obligationAddress(owner)])
+ // Closed: no data, no rent left in it. Simulation reports that as an empty account.
+ expect(result.accounts?.[0]?.lamports ?? 0).toBe(0)
+ expect(result.accounts?.[0]?.data[0] ?? '').toBe('')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'positions reads a real one the way the chain’s own figures do',
+ async () => {
+ const [first] = await positionsWith(SOL.reserve, USDC.reserve)
+ const positions = await venue.positions?.(first?.owner as string)
+ expect(positions?.some((p) => p.slot === 'supplied' && p.symbol === 'SOL')).toBe(true)
+ expect(positions?.some((p) => p.slot === 'borrowed' && p.symbol === 'USDC')).toBe(true)
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/kamino-lend.test.ts b/apps/dapp/lib/__tests__/kamino-lend.test.ts
new file mode 100644
index 00000000..fa796b42
--- /dev/null
+++ b/apps/dapp/lib/__tests__/kamino-lend.test.ts
@@ -0,0 +1,972 @@
+import { describe, expect, it } from 'vitest'
+
+import { TOKEN_PROGRAM, associatedTokenAccount } from '../chains/solana/assert-safe'
+import {
+ assertKaminoLendSafe,
+ KAMINO_LEND_RESERVES,
+ KAMINO_MAIN_MARKET,
+ KLEND_PROGRAM,
+ obligationAddress,
+ reserveListing,
+ U64_MAX,
+ userMetadataAddress,
+} from '../chains/solana/kamino-lend-rules'
+import { createKaminoLendVenue } from '../chains/solana/kamino-lend'
+import { kaminoLendListing } from '../chains/solana/kamino-lend-listing'
+import { KFARMS_PROGRAM } from '../chains/solana/programs'
+import { readU64, type SolanaRpc } from '../chains/solana/stake-pool'
+import { ownWrappedSolAccount } from '../chains/solana/assert-safe'
+import { ChainActionRefused, type ChainActionRequest, type PreparedAction } from '../chains/types'
+import {
+ addressBytes,
+ BLOCKHASH,
+ budget,
+ instructionsOf,
+ ME,
+ payload,
+ rebuilt,
+ SOMEONE_ELSE,
+ transferOf,
+ u64Bytes,
+ UNKNOWN_PROGRAM,
+ unlimitedApproval,
+ withAccount,
+ withExtraData,
+ withOtherFunction,
+} from './solana-stake-fixtures'
+
+const SYSTEM = '11111111111111111111111111111111'
+const USDC_RESERVE = reserveListing('USDC')
+const SOL_RESERVE = reserveListing('SOL')
+const JITOSOL_RESERVE = reserveListing('JitoSOL')
+const OBLIGATION = obligationAddress(ME)
+const FARM = 'JAvnB9AKtgPsTEoKmn24Bq64UMoYcrtWtq42HHBdsPkh'
+const SCOPE = '3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH'
+const SF = BigInt(1) << BigInt(60)
+const TEN_POW_6 = BigInt(1_000_000)
+
+const discriminator = {
+ obligation: [0xa8, 0xce, 0x8d, 0x6a, 0x58, 0x4c, 0xac, 0xa7],
+ reserve: [0x2b, 0xf2, 0xcc, 0xca, 0x1a, 0xf7, 0x3b, 0x7f],
+}
+
+interface ReserveFixture {
+ reserve: string
+ mint: string
+ decimals: number
+ /** Dollars per token, as a whole number of millionths. */
+ priceMicros: bigint
+ available: bigint
+ borrowed: bigint
+ cTokens: bigint
+ ltv: number
+ threshold: number
+ borrowFactor?: bigint
+ borrowLimit?: bigint
+ status?: number
+ farmCollateral?: string
+ farmDebt?: string
+ takeRate?: number
+ hostFixedBps?: number
+ originationSf?: bigint
+ curve?: [number, number][]
+ cumulative?: bigint
+ tokenProgram?: string
+ name?: string
+}
+
+function put(target: Uint8Array, at: number, bytes: readonly number[]): void {
+ target.set(Uint8Array.from(bytes), at)
+}
+
+function u128(value: bigint): number[] {
+ return Array.from({ length: 16 }, (_, i) => Number((value >> BigInt(8 * i)) & BigInt(255)))
+}
+
+function encodeReserve(fixture: ReserveFixture): Uint8Array {
+ const data = new Uint8Array(8624)
+ put(data, 0, discriminator.reserve)
+ put(data, 8, u64Bytes(BigInt(1)))
+ new DataView(data.buffer).setUint32(28, Math.floor(Date.now() / 1000), true)
+ put(data, 32, addressBytes(KAMINO_MAIN_MARKET))
+ put(data, 64, addressBytes(fixture.farmCollateral ?? SYSTEM))
+ put(data, 96, addressBytes(fixture.farmDebt ?? SYSTEM))
+ put(data, 128, addressBytes(fixture.mint))
+ put(data, 160, addressBytes('Bgq7trRgVMeq33yt235zM2onQ4bRDBsY5EWiTetF4qw6'))
+ put(data, 192, addressBytes('BbDUrk1bVtSixgQsPLBJFZEF7mwGstnD5joA1WzYvYFX'))
+ put(data, 224, u64Bytes(fixture.available))
+ put(data, 232, u128(fixture.borrowed * SF))
+ put(data, 248, u128((fixture.priceMicros * SF) / TEN_POW_6))
+ put(data, 272, u64Bytes(BigInt(fixture.decimals)))
+ put(data, 296, u128(fixture.cumulative ?? SF))
+ put(data, 408, addressBytes(fixture.tokenProgram ?? TOKEN_PROGRAM))
+ put(data, 2560, addressBytes('B8V6WVjPxW1UGwVDfxH2d2r8SyT4cqn7dQRK6XneVa7D'))
+ put(data, 2592, u64Bytes(fixture.cTokens))
+ put(data, 2600, addressBytes('3DzjXRfxRm6iejfyyMynR4tScddaanrePJ1NJU2XnPPL'))
+ data[4856] = fixture.status ?? 0
+ put(data, 4858, [fixture.hostFixedBps ?? 0, 0])
+ data[4870] = fixture.takeRate ?? 10
+ data[4872] = fixture.ltv
+ data[4873] = fixture.threshold
+ put(data, 4874, [100, 0])
+ put(data, 4876, [0xe8, 0x03])
+ put(data, 4896, u64Bytes(fixture.originationSf ?? BigInt(0)))
+ const curve = fixture.curve ?? [
+ [0, 0],
+ [10000, 1000],
+ ]
+ curve.forEach(([utilization, rate], i) => {
+ put(data, 4920 + i * 8, [
+ utilization & 255,
+ utilization >> 8,
+ 0,
+ 0,
+ rate & 255,
+ rate >> 8,
+ 0,
+ 0,
+ ])
+ })
+ put(data, 5008, u64Bytes(fixture.borrowFactor ?? BigInt(100)))
+ put(data, 5024, u64Bytes(fixture.borrowLimit ?? BigInt('100000000000000000')))
+ const name = new TextEncoder().encode(fixture.name ?? 'TOKEN')
+ data.set(name, 5032)
+ put(data, 5112, addressBytes(SCOPE))
+ put(data, 5160, addressBytes(SYSTEM))
+ put(data, 5192, addressBytes(SYSTEM))
+ put(data, 5224, addressBytes(SYSTEM))
+ return data
+}
+
+interface ObligationFixture {
+ owner?: string
+ deposits?: { reserve: string; cTokens: bigint }[]
+ borrows?: { reserve: string; amount: bigint; cumulative?: bigint }[]
+ elevationGroup?: number
+ referrer?: string
+}
+
+function encodeObligation(fixture: ObligationFixture): Uint8Array {
+ const data = new Uint8Array(3344)
+ put(data, 0, discriminator.obligation)
+ put(data, 32, addressBytes(KAMINO_MAIN_MARKET))
+ put(data, 64, addressBytes(fixture.owner ?? ME))
+ ;(fixture.deposits ?? []).forEach(({ reserve, cTokens }, i) => {
+ put(data, 96 + i * 136, addressBytes(reserve))
+ put(data, 96 + i * 136 + 32, u64Bytes(cTokens))
+ })
+ ;(fixture.borrows ?? []).forEach(({ reserve, amount, cumulative }, i) => {
+ put(data, 1208 + i * 200, addressBytes(reserve))
+ put(data, 1208 + i * 200 + 32, u128(cumulative ?? SF))
+ put(data, 1208 + i * 200 + 88, u128(amount * SF))
+ })
+ data[2285] = fixture.elevationGroup ?? 0
+ put(data, 2288, addressBytes(fixture.referrer ?? SYSTEM))
+ return data
+}
+
+const USDC: ReserveFixture = {
+ reserve: USDC_RESERVE.reserve,
+ mint: USDC_RESERVE.mint,
+ decimals: 6,
+ priceMicros: TEN_POW_6,
+ available: BigInt(1_000_000_000_000),
+ borrowed: BigInt(1_000_000_000_000),
+ cTokens: BigInt(1_000_000_000_000),
+ ltv: 80,
+ threshold: 90,
+ farmCollateral: FARM,
+ hostFixedBps: 50,
+}
+
+const JITOSOL: ReserveFixture = {
+ reserve: JITOSOL_RESERVE.reserve,
+ mint: JITOSOL_RESERVE.mint,
+ decimals: 9,
+ priceMicros: BigInt(100_000_000),
+ available: BigInt('1000000000000000'),
+ borrowed: BigInt(0),
+ cTokens: BigInt('1000000000000000'),
+ ltv: 50,
+ threshold: 60,
+ borrowLimit: BigInt(0),
+}
+
+const SOL: ReserveFixture = {
+ reserve: SOL_RESERVE.reserve,
+ mint: SOL_RESERVE.mint,
+ decimals: 9,
+ priceMicros: BigInt(100_000_000),
+ available: BigInt('1000000000000000'),
+ borrowed: BigInt('1000000000000000'),
+ cTokens: BigInt('1000000000000000'),
+ ltv: 70,
+ threshold: 80,
+ borrowFactor: BigInt(125),
+ farmCollateral: 'So11111111111111111111111111111111111111112',
+}
+
+interface FakeChain {
+ reserves?: ReserveFixture[]
+ obligation?: ObligationFixture
+ metadata?: boolean
+ farmUserStates?: string[]
+ simulation?: { err: unknown; logs: string[] }
+ unreadable?: boolean
+}
+
+function chain(fake: FakeChain): SolanaRpc & { simulated: string[]; calls: string[] } {
+ const simulated: string[] = []
+ const calls: string[] = []
+ const accounts = new Map()
+ for (const reserve of fake.reserves ?? [USDC, JITOSOL, SOL]) {
+ accounts.set(reserve.reserve, { owner: KLEND_PROGRAM, data: encodeReserve(reserve) })
+ }
+ if (fake.obligation !== undefined) {
+ accounts.set(OBLIGATION, { owner: KLEND_PROGRAM, data: encodeObligation(fake.obligation) })
+ }
+ if (fake.metadata === true) {
+ accounts.set(userMetadataAddress(ME), { owner: KLEND_PROGRAM, data: new Uint8Array(1032) })
+ }
+ for (const state of fake.farmUserStates ?? []) {
+ accounts.set(state, { owner: KFARMS_PROGRAM, data: new Uint8Array(920) })
+ }
+ const rpc = async (method: string, params: unknown[]): Promise => {
+ calls.push(method)
+ if (fake.unreadable === true) throw new ChainActionRefused('Solana could not be read.')
+ switch (method) {
+ case 'getMultipleAccounts':
+ return {
+ value: (params[0] as string[]).map((address) => {
+ const account = accounts.get(address)
+ return account === undefined
+ ? null
+ : {
+ owner: account.owner,
+ lamports: 1,
+ data: [Buffer.from(account.data).toString('base64'), 'base64'],
+ }
+ }),
+ }
+ case 'getLatestBlockhash':
+ return { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }
+ case 'simulateTransaction':
+ simulated.push(params[0] as string)
+ return { value: fake.simulation ?? { err: null, logs: [] } }
+ default:
+ throw new Error(`This test must not call ${method}.`)
+ }
+ }
+ return Object.assign(rpc, { simulated, calls })
+}
+
+const ask = (
+ kind: ChainActionRequest['kind'],
+ amount: string,
+ tokenIn: string,
+ extra: Partial = {}
+): ChainActionRequest => ({ kind, amount, tokenIn, ...extra })
+
+const prepareOn = (fake: FakeChain, request: ChainActionRequest): Promise =>
+ createKaminoLendVenue({ rpc: chain(fake) }).prepare(request, ME)
+
+/** Pulls apart what was built: each step by name, as the program's own discriminators say. */
+const NAMES: Record = {
+ '75a9b045c5170fa2': 'init_user_metadata',
+ fb0ae74c1b0b9f60: 'init_obligation',
+ '883f0fbad398a8a4': 'init_obligation_farms',
+ '02da8aeb4fc91966': 'refresh_reserve',
+ '218493e497c04859': 'refresh_obligation',
+ d8e0bf1bcc9766af: 'deposit',
+ eb34779895c51407: 'withdraw',
+ a1808ff5abc7c206: 'borrow',
+ '74aed54cb435d290': 'repay',
+}
+
+function hex(bytes: Uint8Array): string {
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
+}
+
+function steps(prepared: PreparedAction): string[] {
+ return instructionsOf(prepared)
+ .filter((i) => !i.programAddress.startsWith('ComputeBudget'))
+ .map((i) => {
+ const data = i.data ?? new Uint8Array()
+ if (i.programAddress === KLEND_PROGRAM) return NAMES[hex(data.subarray(0, 8))] ?? 'unknown'
+ if (i.programAddress === SYSTEM) return 'wrap'
+ if (i.programAddress === TOKEN_PROGRAM) return data[0] === 17 ? 'sync' : 'close'
+ return 'create_token_account'
+ })
+}
+
+function action(prepared: PreparedAction, name: string) {
+ const found = instructionsOf(prepared).find(
+ (i) =>
+ i.programAddress === KLEND_PROGRAM &&
+ NAMES[hex((i.data ?? []).slice(0, 8) as Uint8Array)] === name
+ )
+ if (found === undefined) throw new Error(`No ${name} step.`)
+ return found
+}
+
+const addressAt = (instruction: ReturnType, position: number): string =>
+ instruction.accounts?.[position]?.address as string
+const amountOf = (instruction: ReturnType): bigint =>
+ readU64(instruction.data as Uint8Array, 8)
+
+const HOLDING: FakeChain = {
+ metadata: true,
+ obligation: { deposits: [{ reserve: USDC.reserve, cTokens: BigInt(1_000_000_000) }] },
+ farmUserStates: [],
+}
+
+describe('the pins', () => {
+ it('lists its own reserves against the mints the chain table uses', () => {
+ expect(KAMINO_LEND_RESERVES.map((r) => r.symbol)).toEqual([
+ 'SOL',
+ 'USDC',
+ 'USDT',
+ 'JitoSOL',
+ 'mSOL',
+ ])
+ })
+
+ it('is listed as its own app, apart from Kamino Earn', () => {
+ expect(kaminoLendListing).toMatchObject({ id: 'kamino-lend', integration: 'executes' })
+ expect(createKaminoLendVenue({ rpc: chain({}) }).name).toBe('Kamino Lend')
+ expect(createKaminoLendVenue({ rpc: chain({}) }).kinds).toEqual([
+ 'supply',
+ 'withdraw',
+ 'borrow',
+ 'repay',
+ ])
+ })
+})
+
+describe('supply', () => {
+ it('opens the position on a first deposit and sends the right amount from the person’s account', async () => {
+ const prepared = await prepareOn({}, ask('supply', '12.5', 'USDC'))
+ expect(steps(prepared)).toEqual([
+ 'init_user_metadata',
+ 'init_obligation',
+ 'init_obligation_farms',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'deposit',
+ ])
+ const deposit = action(prepared, 'deposit')
+ expect(amountOf(deposit)).toBe(BigInt(12_500_000))
+ expect(addressAt(deposit, 0)).toBe(ME)
+ expect(addressAt(deposit, 1)).toBe(OBLIGATION)
+ expect(addressAt(deposit, 4)).toBe(USDC.reserve)
+ expect(addressAt(deposit, 9)).toBe(associatedTokenAccount(ME, USDC.mint))
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('skips the setup once the position exists', async () => {
+ const prepared = await prepareOn(HOLDING, ask('supply', '1', 'JitoSOL'))
+ expect(steps(prepared)).toEqual([
+ 'refresh_reserve',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'deposit',
+ ])
+ })
+
+ it('wraps SOL first, and returns what is left in the wrapped account', async () => {
+ const prepared = await prepareOn(HOLDING, ask('supply', '1', 'SOL'))
+ expect(steps(prepared)).toEqual([
+ 'create_token_account',
+ 'wrap',
+ 'sync',
+ 'init_obligation_farms',
+ 'refresh_reserve',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'deposit',
+ 'close',
+ ])
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('says plainly this backs a loan, and that Kamino Earn does not', async () => {
+ const prepared = await prepareOn({}, ask('supply', '1', 'USDC'))
+ const text = prepared.notes.join(' ')
+ expect(prepared.venueName).toBe('Kamino Lend')
+ expect(text).toMatch(/Kamino Earn/)
+ expect(text).toMatch(/can back a loan/i)
+ expect(text).toMatch(/80%/)
+ })
+
+ it('fits the widest first deposit in one transaction', async () => {
+ const prepared = await prepareOn({}, ask('supply', '1', 'SOL'))
+ expect(steps(prepared)).toEqual([
+ 'create_token_account',
+ 'wrap',
+ 'sync',
+ 'init_user_metadata',
+ 'init_obligation',
+ 'init_obligation_farms',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'deposit',
+ 'close',
+ ])
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('states what the first deposit costs in rent', async () => {
+ const prepared = await prepareOn({}, ask('supply', '1', 'USDC'))
+ expect(prepared.notes.join(' ')).toMatch(/0\.03\d* SOL/)
+ })
+
+ it('reads the rate from the reserve’s own curve', async () => {
+ const prepared = await prepareOn({}, ask('supply', '1', 'USDC'))
+ // Half lent, and the curve is 0% to 10% over all use: 5% to borrowers, of which savers get half less 10%.
+ expect(prepared.apy?.basePercent).toBeCloseTo((Math.exp(0.05 * 0.5 * 0.9) - 1) * 100, 1)
+ })
+
+ it('refuses a token it has no reserve for', async () => {
+ await expect(prepareOn({}, ask('supply', '1', 'BONK'))).rejects.toThrow(/takes SOL, USDC/)
+ })
+
+ it('refuses a reserve no longer active', async () => {
+ await expect(
+ prepareOn({ reserves: [{ ...USDC, status: 1 }] }, ask('supply', '1', 'USDC'))
+ ).rejects.toThrow(/isn’t taking/)
+ })
+
+ it('refuses a reserve that has moved to another token', async () => {
+ await expect(
+ prepareOn({ reserves: [{ ...USDC, mint: JITOSOL.mint }] }, ask('supply', '1', 'USDC'))
+ ).rejects.toThrow(/doesn’t look as expected/)
+ })
+})
+
+describe('borrow', () => {
+ const COLLATERAL: FakeChain = {
+ metadata: true,
+ // Ten JitoSOL at $100: $1,000 of value, of which half can be borrowed against.
+ obligation: { deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }] },
+ }
+
+ it('sends what is borrowed to the person’s own account and refreshes first', async () => {
+ const prepared = await prepareOn(COLLATERAL, ask('borrow', '100', 'USDC'))
+ expect(steps(prepared)).toEqual([
+ 'create_token_account',
+ 'refresh_reserve',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'borrow',
+ ])
+ const borrow = action(prepared, 'borrow')
+ expect(amountOf(borrow)).toBe(BigInt(100_000_000))
+ expect(addressAt(borrow, 8)).toBe(associatedTokenAccount(ME, USDC.mint))
+ expect(prepared.pay).toEqual({ amount: '100', symbol: 'USDC' })
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('refuses when nothing backs it, and says what to do first', async () => {
+ await expect(prepareOn({}, ask('borrow', '1', 'USDC'))).rejects.toThrow(/supply/i)
+ await expect(
+ prepareOn({ metadata: true, obligation: {} }, ask('borrow', '1', 'USDC'))
+ ).rejects.toThrow(/supply/i)
+ })
+
+ it('refuses more than the position supports, before building anything to sign', async () => {
+ const rpc = chain(COLLATERAL)
+ const attempt = createKaminoLendVenue({ rpc }).prepare(ask('borrow', '501', 'USDC'), ME)
+ await expect(attempt).rejects.toThrow(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/at most about 500/)
+ await expect(attempt).rejects.toThrow(/Nothing was signed or sent/)
+ expect(rpc.simulated).toEqual([])
+ })
+
+ it('counts what is already owed, and the risk weight on the loan', async () => {
+ const owing: FakeChain = {
+ metadata: true,
+ obligation: {
+ deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }],
+ // One SOL at $100 weighted 125% uses $125 of the $500.
+ borrows: [{ reserve: SOL.reserve, amount: BigInt(1_000_000_000) }],
+ },
+ }
+ await expect(prepareOn(owing, ask('borrow', '376', 'USDC'))).rejects.toThrow(
+ /at most about 37[45]/
+ )
+ await expect(prepareOn(owing, ask('borrow', '374', 'USDC'))).resolves.toBeDefined()
+ })
+
+ it('counts an opening fee against the limit', async () => {
+ const reserves = [{ ...USDC, originationSf: SF / BigInt(100) }, JITOSOL, SOL]
+ await expect(
+ prepareOn({ ...COLLATERAL, reserves }, ask('borrow', '500', 'USDC'))
+ ).rejects.toThrow(/at most about 495/)
+ })
+
+ it('refuses a token Kamino won’t lend', async () => {
+ await expect(prepareOn(COLLATERAL, ask('borrow', '1', 'JitoSOL'))).rejects.toThrow(
+ /can’t be borrowed/
+ )
+ })
+
+ it('refuses when the pool hasn’t enough ready', async () => {
+ await expect(
+ prepareOn(
+ { ...COLLATERAL, reserves: [{ ...USDC, available: BigInt(1_000_000) }, JITOSOL, SOL] },
+ ask('borrow', '100', 'USDC')
+ )
+ ).rejects.toThrow(/ready to lend/)
+ })
+
+ it('refuses collateral in one of Kamino’s special modes, whose limits it doesn’t read', async () => {
+ await expect(
+ prepareOn(
+ {
+ metadata: true,
+ obligation: {
+ elevationGroup: 1,
+ deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }],
+ },
+ },
+ ask('borrow', '1', 'USDC')
+ )
+ ).rejects.toThrow(/special mode/)
+ })
+
+ it('refuses when the network’s own run says the program would reject it', async () => {
+ await expect(
+ prepareOn(
+ {
+ ...COLLATERAL,
+ simulation: {
+ err: { InstructionError: [4, { Custom: 6000 }] },
+ logs: ['Program log: AnchorError occurred. Error Code: BorrowTooLarge.'],
+ },
+ },
+ ask('borrow', '100', 'USDC')
+ )
+ ).rejects.toThrow(/more than this position can support/)
+ })
+
+ it('shows the liquidation risk in plain words, the rate and the fee', async () => {
+ const prepared = await prepareOn(COLLATERAL, ask('borrow', '300', 'USDC'))
+ const text = prepared.notes.join(' ')
+ // $300 owed against a $600 line (60% of $1,000): liquidation if collateral falls about half.
+ expect(text).toMatch(/\$300/)
+ expect(text).toMatch(/\$1,000/)
+ expect(text).toMatch(/about 50%/)
+ expect(text).toMatch(/sell your collateral/)
+ // 50% used on a 0% to 10% curve, with 0.5% on top.
+ expect(text).toMatch(/5\.6\d?% a year/)
+ expect(text).not.toMatch(/\bLTV\b|\bcollateral factor\b/)
+ })
+})
+
+describe('withdraw', () => {
+ it('turns an amount into the receipt tokens that cover it and pays the person', async () => {
+ const prepared = await prepareOn(HOLDING, ask('withdraw', '100', 'USDC'))
+ expect(steps(prepared)).toEqual([
+ 'create_token_account',
+ 'init_obligation_farms',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'withdraw',
+ ])
+ const withdraw = action(prepared, 'withdraw')
+ // Reserve holds $2M for 1M receipts, so $100 takes 50.
+ expect(amountOf(withdraw)).toBe(BigInt(50_000_000))
+ expect(addressAt(withdraw, 9)).toBe(associatedTokenAccount(ME, USDC.mint))
+ expect(prepared.receive).toEqual({ amount: '100', symbol: 'USDC', approximate: true })
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('empties a position exactly when asked for the whole of it', async () => {
+ const prepared = await prepareOn(
+ HOLDING,
+ ask('withdraw', '2000', 'USDC', { wholePosition: true })
+ )
+ expect(amountOf(action(prepared, 'withdraw'))).toBe(U64_MAX)
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('won’t empty a position that still backs a loan', async () => {
+ await expect(
+ prepareOn(
+ {
+ metadata: true,
+ obligation: {
+ deposits: [{ reserve: USDC.reserve, cTokens: BigInt(1_000_000_000) }],
+ borrows: [{ reserve: SOL.reserve, amount: BigInt(1_000_000_000) }],
+ },
+ },
+ ask('withdraw', '2000', 'USDC', { wholePosition: true })
+ )
+ ).rejects.toThrow(/still owe/)
+ })
+
+ it('refuses more than is there', async () => {
+ await expect(prepareOn(HOLDING, ask('withdraw', '2001', 'USDC'))).rejects.toThrow(
+ /You have 2000/
+ )
+ })
+
+ it('refuses when there is nothing at all', async () => {
+ await expect(prepareOn({}, ask('withdraw', '1', 'USDC'))).rejects.toThrow(/no USDC/)
+ })
+
+ it('refuses when the pool hasn’t enough ready, and says why', async () => {
+ await expect(
+ prepareOn(
+ { ...HOLDING, reserves: [{ ...USDC, available: BigInt(1_000_000) }] },
+ ask('withdraw', '100', 'USDC')
+ )
+ ).rejects.toThrow(/borrowers repay/)
+ })
+
+ it('unwraps SOL back into the wallet', async () => {
+ const prepared = await prepareOn(
+ {
+ metadata: true,
+ obligation: { deposits: [{ reserve: SOL.reserve, cTokens: BigInt(1_000_000_000) }] },
+ },
+ ask('withdraw', '1', 'SOL')
+ )
+ expect(steps(prepared).at(-1)).toBe('close')
+ expect(steps(prepared)[0]).toBe('create_token_account')
+ expect(addressAt(action(prepared, 'withdraw'), 9)).toBe(ownWrappedSolAccount(ME))
+ })
+})
+
+describe('repay', () => {
+ const OWING: FakeChain = {
+ metadata: true,
+ obligation: {
+ deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }],
+ borrows: [{ reserve: USDC.reserve, amount: BigInt(100_000_000) }],
+ },
+ }
+
+ it('pays down what was asked, from the person’s own account', async () => {
+ const prepared = await prepareOn(OWING, ask('repay', '40', 'USDC'))
+ expect(steps(prepared)).toEqual([
+ 'refresh_reserve',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'repay',
+ ])
+ const repay = action(prepared, 'repay')
+ expect(amountOf(repay)).toBe(BigInt(40_000_000))
+ expect(addressAt(repay, 6)).toBe(associatedTokenAccount(ME, USDC.mint))
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('clears the debt exactly on a whole repay, interest included', async () => {
+ const prepared = await prepareOn(OWING, ask('repay', '100', 'USDC', { wholePosition: true }))
+ expect(amountOf(action(prepared, 'repay'))).toBe(U64_MAX)
+ expect(prepared.summary).toMatch(/all/i)
+ expect(() => assertKaminoLendSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('wraps a little over a whole SOL debt, and returns what is left', async () => {
+ const prepared = await prepareOn(
+ {
+ metadata: true,
+ obligation: {
+ deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }],
+ borrows: [{ reserve: SOL.reserve, amount: BigInt(1_000_000_000) }],
+ },
+ },
+ ask('repay', '1', 'SOL', { wholePosition: true })
+ )
+ expect(steps(prepared)).toEqual([
+ 'create_token_account',
+ 'wrap',
+ 'sync',
+ 'refresh_reserve',
+ 'refresh_reserve',
+ 'refresh_obligation',
+ 'repay',
+ 'close',
+ ])
+ const wrap = instructionsOf(prepared).find((i) => i.programAddress === SYSTEM)
+ const lamports = readU64(wrap?.data as Uint8Array, 4)
+ expect(lamports).toBeGreaterThan(BigInt(1_000_000_000))
+ expect(lamports).toBeLessThan(BigInt(1_010_000_000))
+ expect(amountOf(action(prepared, 'repay'))).toBe(U64_MAX)
+ })
+
+ it('refuses more than is owed', async () => {
+ await expect(prepareOn(OWING, ask('repay', '101', 'USDC'))).rejects.toThrow(/You owe 100/)
+ })
+
+ it('refuses when nothing is owed in that token', async () => {
+ await expect(prepareOn(OWING, ask('repay', '1', 'SOL'))).rejects.toThrow(/no SOL/)
+ await expect(prepareOn({}, ask('repay', '1', 'USDC'))).rejects.toThrow(/no USDC/)
+ })
+
+ it('says so when a position is too wide for one transaction', async () => {
+ const others: ReserveFixture[] = []
+ let previous = ME
+ for (let i = 0; i < 12; i += 1) {
+ previous = associatedTokenAccount(previous, USDC.mint)
+ others.push({ ...JITOSOL, reserve: previous })
+ }
+ await expect(
+ prepareOn(
+ {
+ metadata: true,
+ reserves: [USDC, ...others],
+ obligation: {
+ referrer: SOMEONE_ELSE,
+ deposits: others.slice(0, 8).map(({ reserve }) => ({ reserve, cTokens: BigInt(1) })),
+ borrows: [
+ { reserve: USDC.reserve, amount: BigInt(100_000_000) },
+ ...others.slice(8).map(({ reserve }) => ({ reserve, amount: BigInt(1) })),
+ ],
+ },
+ },
+ ask('repay', '1', 'USDC')
+ )
+ ).rejects.toThrow(/too many tokens/)
+ })
+
+ it('refreshes every reserve of the position in order, with the referrer’s records', async () => {
+ const referrer = SOMEONE_ELSE
+ const prepared = await prepareOn(
+ {
+ metadata: true,
+ obligation: { ...OWING.obligation, referrer },
+ },
+ ask('repay', '1', 'USDC')
+ )
+ const refresh = action(prepared, 'refresh_obligation')
+ expect(addressAt(refresh, 2)).toBe(JITOSOL.reserve)
+ expect(addressAt(refresh, 3)).toBe(USDC.reserve)
+ expect(refresh.accounts).toHaveLength(5)
+ })
+})
+
+describe('positions', () => {
+ it('is empty when no position exists', async () => {
+ await expect(createKaminoLendVenue({ rpc: chain({}) }).positions?.(ME)).resolves.toEqual([])
+ })
+
+ it('reads what is supplied and owed, interest included', async () => {
+ const positions = await createKaminoLendVenue({
+ rpc: chain({
+ metadata: true,
+ reserves: [{ ...USDC, cumulative: SF * BigInt(2) }, JITOSOL, SOL],
+ obligation: {
+ deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }],
+ // Borrowed when the running rate was 1, and it is 2 now: twice what the record says.
+ borrows: [{ reserve: USDC.reserve, amount: BigInt(100_000_000), cumulative: SF }],
+ },
+ }),
+ }).positions?.(ME)
+ expect(positions?.find((p) => p.slot === 'supplied')).toMatchObject({
+ symbol: 'JitoSOL',
+ amount: '10',
+ })
+ const borrowed = positions?.find((p) => p.slot === 'borrowed')
+ expect(borrowed?.symbol).toBe('USDC')
+ expect(Number(borrowed?.amount)).toBeGreaterThanOrEqual(200)
+ expect(Number(borrowed?.amount)).toBeLessThan(200.01)
+ })
+
+ it('never lets an unlisted reserve pass as a listed token', async () => {
+ const other = '6pazpY4icuXZ5sb2jMWAqdG4TtbUoY1SJ45237Fjht9h'
+ const positions = await createKaminoLendVenue({
+ rpc: chain({
+ metadata: true,
+ reserves: [{ ...USDC, reserve: other, name: 'USDC' }],
+ obligation: { deposits: [{ reserve: other, cTokens: BigInt(1_000_000) }] },
+ }),
+ }).positions?.(ME)
+ expect(positions?.[0]?.symbol).toBe('USDC (not listed here)')
+ })
+
+ it('throws when the chain can’t be read, rather than saying nothing is there', async () => {
+ await expect(
+ createKaminoLendVenue({ rpc: chain({ unreadable: true }) }).positions?.(ME)
+ ).rejects.toThrow()
+ })
+
+ it('throws on a position that isn’t the person’s', async () => {
+ await expect(
+ createKaminoLendVenue({
+ rpc: chain({ obligation: { owner: SOMEONE_ELSE } }),
+ }).positions?.(ME)
+ ).rejects.toThrow()
+ })
+
+ it('throws when a reserve it needs is missing', async () => {
+ await expect(
+ createKaminoLendVenue({
+ rpc: chain({
+ reserves: [],
+ obligation: { deposits: [{ reserve: USDC.reserve, cTokens: BigInt(1) }] },
+ }),
+ }).positions?.(ME)
+ ).rejects.toThrow()
+ })
+})
+
+describe('what is refused at signing', () => {
+ const build = (fake: FakeChain, request: ChainActionRequest) => prepareOn(fake, request)
+ const OWING: FakeChain = {
+ metadata: true,
+ obligation: {
+ deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }],
+ borrows: [{ reserve: USDC.reserve, amount: BigInt(100_000_000) }],
+ },
+ }
+ const COLLATERAL: FakeChain = {
+ metadata: true,
+ obligation: { deposits: [{ reserve: JITOSOL.reserve, cTokens: BigInt(10_000_000_000) }] },
+ }
+
+ function replaced(
+ prepared: PreparedAction,
+ name: string,
+ change: (i: ReturnType) => ReturnType
+ ) {
+ const target = hex(Uint8Array.from(action(prepared, name).data ?? []))
+ return [
+ ...budget,
+ ...rebuilt(prepared, ME)
+ .filter((i) => !i.programAddress.startsWith('ComputeBudget'))
+ .map((i) => (hex(Uint8Array.from(i.data ?? [])) === target ? change(i) : i)),
+ ]
+ }
+
+ it('accepts its own build, rebuilt unchanged', async () => {
+ const prepared = await build(COLLATERAL, ask('borrow', '100', 'USDC'))
+ expect(() =>
+ assertKaminoLendSafe(
+ payload(
+ ME,
+ replaced(prepared, 'borrow', (i) => i)
+ ),
+ ME
+ )
+ ).not.toThrow()
+ })
+
+ it.each([
+ ['borrow', 'to someone else’s account', 8, associatedTokenAccount(SOMEONE_ELSE, USDC.mint)],
+ ['borrow', 'to a listed reserve’s other mint', 5, JITOSOL.mint],
+ ['borrow', 'from someone else’s position', 1, obligationAddress(SOMEONE_ELSE)],
+ ['borrow', 'in another market', 2, 'DxXdAyU3kCjnyggvHmY5nAwg5cRbbmdyX3npfDMjjMek'],
+ ['borrow', 'from an unlisted reserve', 4, '6pazpY4icuXZ5sb2jMWAqdG4TtbUoY1SJ45237Fjht9h'],
+ ['borrow', 'through another token program', 10, UNKNOWN_PROGRAM],
+ ['borrow', 'with a stranger as the owner', 0, SOMEONE_ELSE],
+ ])('refuses a %s %s', async (name, _what, position, address) => {
+ const prepared = await build(COLLATERAL, ask('borrow', '100', 'USDC'))
+ const tampered = replaced(prepared, name, (i) => withAccount(i, position, address))
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a withdraw paid to someone else', async () => {
+ const prepared = await build(HOLDING, ask('withdraw', '1', 'USDC'))
+ const tampered = replaced(prepared, 'withdraw', (i) =>
+ withAccount(i, 9, associatedTokenAccount(SOMEONE_ELSE, USDC.mint))
+ )
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(/isn’t what Kamino Lend/)
+ })
+
+ it('refuses a repay drawn from someone else’s account', async () => {
+ const prepared = await build(OWING, ask('repay', '1', 'USDC'))
+ const tampered = replaced(prepared, 'repay', (i) =>
+ withAccount(i, 6, associatedTokenAccount(SOMEONE_ELSE, USDC.mint))
+ )
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a deposit against someone else’s position', async () => {
+ const prepared = await build(HOLDING, ask('supply', '1', 'USDC'))
+ const tampered = replaced(prepared, 'deposit', (i) =>
+ withAccount(i, 1, obligationAddress(SOMEONE_ELSE))
+ )
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a borrow of everything the pool will give', async () => {
+ const prepared = await build(COLLATERAL, ask('borrow', '100', 'USDC'))
+ const tampered = replaced(prepared, 'borrow', (i) => ({
+ ...i,
+ data: Uint8Array.from([...(i.data ?? []).slice(0, 8), ...u64Bytes(U64_MAX)]),
+ }))
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it.each([
+ ['extra bytes', withExtraData],
+ ['another function', withOtherFunction],
+ ])('refuses the action with %s', async (_what, change) => {
+ const prepared = await build(COLLATERAL, ask('borrow', '100', 'USDC'))
+ const tampered = replaced(prepared, 'borrow', change)
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a second money step, a transfer out, an approval or an unknown program', async () => {
+ const prepared = await build(COLLATERAL, ask('borrow', '100', 'USDC'))
+ const base = replaced(prepared, 'borrow', (i) => i)
+ const own = associatedTokenAccount(ME, USDC.mint)
+ for (const extra of [
+ action(prepared, 'borrow'),
+ transferOf(ME, SOMEONE_ELSE, BigInt(1000)),
+ unlimitedApproval(own, SOMEONE_ELSE, ME),
+ { programAddress: UNKNOWN_PROGRAM as never, data: Uint8Array.of(1) },
+ ]) {
+ expect(() => assertKaminoLendSafe(payload(ME, [...base, extra]), ME)).toThrow(
+ ChainActionRefused
+ )
+ }
+ })
+
+ it('refuses a position set up with someone else’s referral', async () => {
+ const prepared = await build({}, ask('supply', '1', 'USDC'))
+ const tampered = replaced(prepared, 'init_user_metadata', (i) =>
+ withAccount(i, 3, userMetadataAddress(SOMEONE_ELSE))
+ )
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a farm record not belonging to this position', async () => {
+ const prepared = await build(HOLDING, ask('withdraw', '1', 'USDC'))
+ const tampered = replaced(prepared, 'withdraw', (i) => withAccount(i, 14, SOMEONE_ELSE))
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses SOL wrapped into someone else’s account', async () => {
+ const prepared = await build(HOLDING, ask('supply', '1', 'SOL'))
+ const instructions = rebuilt(prepared, ME).filter(
+ (i) => !i.programAddress.startsWith('ComputeBudget')
+ )
+ const tampered = [
+ ...budget,
+ ...instructions.map((i) =>
+ i.programAddress === SYSTEM
+ ? transferOf(ME, ownWrappedSolAccount(SOMEONE_ELSE), BigInt(1_000_000_000))
+ : i
+ ),
+ ]
+ expect(() => assertKaminoLendSafe(payload(ME, tampered), ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses another wallet as payer or signer', async () => {
+ const prepared = await build(COLLATERAL, ask('borrow', '100', 'USDC'))
+ expect(() => assertKaminoLendSafe(prepared.signing, SOMEONE_ELSE)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses anything but Solana', () => {
+ expect(() => assertKaminoLendSafe({ family: 'sui', transaction: 'AA==' }, ME)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('refuses a transaction with no money step at all', () => {
+ expect(() => assertKaminoLendSafe(payload(ME, [...budget]), ME)).toThrow(ChainActionRefused)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/lend-cap.test.ts b/apps/dapp/lib/__tests__/lend-cap.test.ts
deleted file mode 100644
index 997ce161..00000000
--- a/apps/dapp/lib/__tests__/lend-cap.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import { assertReserveWithinCap } from '../lend/cap'
-import { blendXlm } from '../lend/reserves'
-import { TradeCapExceeded } from '../server/trade-cap'
-import type { MarketPrice } from '../swap/price-types'
-
-/**
- * Blend names a reserve by contract id; the cap prices by symbol. XLM is the
- * one reserve this app can price, so it is the one that can be capped, and a
- * reserve with no price is refused on mainnet rather than estimated.
- */
-
-const asOf = '2026-10-01T00:00:00.000Z'
-const table: Record = {
- XLM: { symbol: 'XLM', usd: 0.2, source: 'reflector', asOf },
-}
-const prices = () => Promise.resolve(table)
-
-describe('capping a Blend amount', () => {
- it('passes an XLM amount under the cap and refuses one over it', async () => {
- // 200 XLM = $40; 300 XLM = $60 against a $50 cap. Base units: 7 decimals.
- await expect(
- assertReserveWithinCap(blendXlm(), '2000000000', {
- env: { MAINNET_MAX_TRADE_USD: '50' },
- network: 'mainnet',
- prices,
- })
- ).resolves.toBeUndefined()
- await expect(
- assertReserveWithinCap(blendXlm(), '3000000000', {
- env: { MAINNET_MAX_TRADE_USD: '50' },
- network: 'mainnet',
- prices,
- })
- ).rejects.toThrow(TradeCapExceeded)
- })
-
- it('refuses a reserve it cannot price', async () => {
- await expect(
- assertReserveWithinCap('CNOTAPRICEDRESERVE', '10000000', {
- env: { MAINNET_MAX_TRADE_USD: '50' },
- network: 'mainnet',
- prices,
- })
- ).rejects.toThrow(/could not be valued/)
- })
-
- it('reads no prices on testnet', async () => {
- await expect(
- assertReserveWithinCap(blendXlm(), '99999999999999', {
- network: 'testnet',
- prices: () => Promise.reject(new Error('must not be asked')),
- })
- ).resolves.toBeUndefined()
- })
-})
diff --git a/apps/dapp/lib/__tests__/marinade.test.ts b/apps/dapp/lib/__tests__/marinade.test.ts
new file mode 100644
index 00000000..496d341a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/marinade.test.ts
@@ -0,0 +1,353 @@
+import { describe, expect, it } from 'vitest'
+
+import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import {
+ createMarinadeVenue,
+ decodeMarinadeState,
+ liquidUnstake,
+ MARINADE_PROGRAM,
+ MARINADE_STATE,
+ MARINADE_TOKENS,
+ MSOL_MINT,
+} from '../chains/solana/marinade'
+import { marinadeListing } from '../chains/solana/marinade-listing'
+import { readU64 } from '../chains/solana/stake-pool'
+import type { PreparedAction } from '../chains/types'
+import {
+ addressBytes,
+ fakeRpc,
+ ME,
+ payload,
+ rebuilt,
+ SOMEONE_ELSE,
+ transferOf,
+ u32Bytes,
+ u64Bytes,
+ UNKNOWN_PROGRAM,
+ unlimitedApproval,
+ withAccount,
+ withExtraData,
+ withOtherFunction,
+} from './solana-stake-fixtures'
+
+const SOL_LEG = 'UefNb6z6yvArqe4cJHTXCqStRsKmWhGxnZzuHbikP5Q'
+const MSOL_LEG = '7GgPYjS5Dza89wV6FpZ23kUJRG5vbQ1GM25ezspYFSoE'
+const TREASURY = 'B1aLzaNMeFVAyQ6f3XbbUyKcH2YPHu2fqiEagmiF23VR'
+
+interface StateFixture {
+ mint?: string
+ msolLeg?: string
+ active?: bigint
+ reserve?: bigint
+ tickets?: bigint
+ supply?: bigint
+ paused?: boolean
+ maxFee?: number
+ minFee?: number
+ target?: bigint
+ minDeposit?: bigint
+ minWithdraw?: bigint
+ cap?: bigint
+}
+
+/** Marinade's `State` at its Borsh offsets, with zeros where this code reads nothing. */
+function encodeState(f: StateFixture = {}): Uint8Array {
+ const data = new Uint8Array(647)
+ const put = (at: number, bytes: number[]): void => data.set(bytes, at)
+ put(8, addressBytes(f.mint ?? MSOL_MINT))
+ put(104, addressBytes(TREASURY))
+ put(138, u64Bytes(BigInt(2039280)))
+ put(376, u64Bytes(f.active ?? BigInt('2233647963872566')))
+ put(420, addressBytes(f.msolLeg ?? MSOL_LEG))
+ put(452, u64Bytes(f.target ?? BigInt('3500000000000')))
+ put(460, u32Bytes(f.maxFee ?? 150))
+ put(464, u32Bytes(f.minFee ?? 3))
+ put(496, u64Bytes(f.reserve ?? BigInt('23619572140625')))
+ put(504, u64Bytes(f.supply ?? BigInt('1593220460250312')))
+ put(528, u64Bytes(f.tickets ?? BigInt('1050140472887')))
+ put(544, u64Bytes(f.minDeposit ?? BigInt(1)))
+ put(552, u64Bytes(f.minWithdraw ?? BigInt(1)))
+ put(560, u64Bytes(f.cap ?? BigInt('18446744073709551615')))
+ data[608] = f.paused === true ? 1 : 0
+ put(609, u32Bytes(2000))
+ data[638] = 2
+ return data
+}
+
+function venueWith(state: StateFixture = {}, solLeg = BigInt('116426934088')) {
+ const rpc = fakeRpc({
+ epoch: 1077,
+ accounts: {
+ [MARINADE_STATE]: { owner: MARINADE_PROGRAM, lamports: BigInt(1), data: encodeState(state) },
+ [SOL_LEG]: {
+ owner: '11111111111111111111111111111111',
+ lamports: solLeg,
+ data: new Uint8Array(0),
+ },
+ },
+ })
+ return { venue: createMarinadeVenue({ rpc }), rpc }
+}
+
+const stake = { kind: 'stake', amount: '1', tokenIn: 'SOL' } as const
+const unstake = { kind: 'unstake', amount: '1', tokenIn: 'mSOL' } as const
+
+const preparedStake = (): Promise => venueWith().venue.prepare(stake, ME)
+const preparedUnstake = (): Promise => venueWith().venue.prepare(unstake, ME)
+
+function refuses(action: () => unknown, pattern: RegExp): void {
+ expect(action).toThrow(pattern)
+}
+
+describe('marinade stake', () => {
+ it('reads the amount from the state, with no rate invented', async () => {
+ const prepared = await preparedStake()
+ expect(prepared.receive?.symbol).toBe('mSOL')
+ expect(Number(prepared.receive?.amount)).toBeCloseTo(0.70615, 4)
+ expect(prepared.apy).toBeUndefined()
+ expect(prepared.notes.join(' ')).toMatch(/not showing a yearly rate/)
+ expect(prepared.notes.join(' ')).toMatch(/6-of-13/)
+ expect(prepared.notes.join(' ')).toMatch(/3-of-5/)
+ expect(prepared.notes.join(' ')).toMatch(/Neodyme, Ackee and Kudelski/)
+ })
+
+ it('deposits the amount asked, from the person, to their own mSOL account', async () => {
+ const prepared = await preparedStake()
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ expect(decoded.feePayer).toBe(ME)
+ expect(decoded.signerCount).toBe(1)
+ const deposit = decoded.instructions.at(-1)!
+ expect(decoded.staticAccounts[deposit.programIndex]).toBe(MARINADE_PROGRAM)
+ expect(readU64(deposit.data, 8)).toBe(BigInt('1000000000'))
+ expect(decoded.staticAccounts[deposit.accounts[6]!]).toBe(ME)
+ expect(decoded.staticAccounts[deposit.accounts[7]!]).toBe(associatedTokenAccount(ME, MSOL_MINT))
+ })
+
+ it('passes its own check, and a quote builds nothing', async () => {
+ const { venue, rpc } = venueWith()
+ const prepared = await venue.prepare(stake, ME)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const quoting = venueWith()
+ const quoted = await quoting.venue.quote?.(stake, ME)
+ expect(quoted?.receive?.symbol).toBe('mSOL')
+ expect(quoted?.apyPercent).toBeUndefined()
+ expect(quoting.rpc.calls).not.toContain('getLatestBlockhash')
+ expect(rpc.calls).toContain('getLatestBlockhash')
+ })
+
+ it('refuses another token, when paused, over the cap, under the minimum', async () => {
+ await expect(venueWith().venue.prepare({ ...stake, tokenIn: 'USDC' }, ME)).rejects.toThrow(
+ /takes SOL/
+ )
+ await expect(venueWith({ paused: true }).venue.prepare(stake, ME)).rejects.toThrow(/paused/)
+ await expect(venueWith({ cap: BigInt(1) }).venue.prepare(stake, ME)).rejects.toThrow(
+ /cap is full/
+ )
+ await expect(
+ venueWith({ minDeposit: BigInt('2000000000') }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/at least 2 SOL/)
+ })
+
+ it('refuses a state with another mint or another pool leg', async () => {
+ await expect(
+ venueWith({ mint: 'So11111111111111111111111111111111111111112' }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/doesn’t look as expected/)
+ await expect(venueWith({ msolLeg: SOMEONE_ELSE }).venue.prepare(stake, ME)).rejects.toThrow(
+ /doesn’t look as expected/
+ )
+ })
+})
+
+describe('marinade unstake', () => {
+ it('calls it a sale into the pool, and names the fee and its ceiling', async () => {
+ const prepared = await preparedUnstake()
+ expect(prepared.kind).toBe('unstake')
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'mSOL' })
+ expect(prepared.receive?.symbol).toBe('SOL')
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/sale into Marinade’s SOL pool/)
+ expect(notes).toMatch(/takes no minimum/)
+ expect(notes).toMatch(/1\.5% maximum/)
+ expect(notes).toMatch(/delayed unstake charges 0\.2%/)
+ })
+
+ it('computes the fee as the program does, falling as the pool fills', () => {
+ const state = decodeMarinadeState(encodeState())!
+ const msol = BigInt('1000000000')
+ const thin = liquidUnstake(state, msol, BigInt('116426934088'))
+ expect(thin.feeBp).toBe(BigInt(146))
+ const full = liquidUnstake(state, msol, BigInt('5000000000000'))
+ expect(full.feeBp).toBe(BigInt(3))
+ const drained = liquidUnstake(state, msol, BigInt(2039280))
+ expect(drained.feeBp).toBe(BigInt(150))
+ expect(full.lamports).toBeGreaterThan(thin.lamports)
+ })
+
+ it('refuses more than the pool holds, and a fee over the ceiling', async () => {
+ await expect(
+ venueWith({}, BigInt(1_000_000_000)).venue.prepare({ ...unstake, amount: '5' }, ME)
+ ).rejects.toThrow(/doesn’t hold enough SOL/)
+ await expect(venueWith({ maxFee: 500 }).venue.prepare(unstake, ME)).rejects.toThrow(/too high/)
+ })
+
+ it('refuses a token that is not mSOL', async () => {
+ await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'SOL' }, ME)).rejects.toThrow(
+ /takes mSOL/
+ )
+ })
+})
+
+describe('marinade unstake of everything', () => {
+ it('sells exactly the balance read, so nothing is left behind, and pays only the person', async () => {
+ const prepared = await venueWith().venue.prepare(
+ { ...unstake, amount: '4.410107252', wholePosition: true },
+ ME
+ )
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ const sale = decoded.instructions.at(-1)!
+ expect(readU64(sale.data, 8)).toBe(BigInt('4410107252'))
+ expect(decoded.staticAccounts[sale.accounts[7]!]).toBe(ME)
+ expect(() => venueWith().venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+})
+
+describe('marinade assertSafe', () => {
+ const venue = venueWith().venue
+
+ it('refuses the wrong signer', async () => {
+ const prepared = await preparedStake()
+ refuses(() => venue.assertSafe(prepared.signing, SOMEONE_ELSE), /network fee/)
+ refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rebuilt(prepared, ME)), ME), /network fee/)
+ })
+
+ it('refuses an unknown program', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const last = rest.at(-1)!
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ { ...last, programAddress: UNKNOWN_PROGRAM as never },
+ ]),
+ ME
+ ),
+ /should not need/
+ )
+ })
+
+ it('refuses an unknown function', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(payload(ME, [...rest.slice(0, -1), withOtherFunction(rest.at(-1)!)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses extra calldata', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(rest.at(-1)!)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses mSOL going to someone else', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const theirs = associatedTokenAccount(SOMEONE_ELSE, MSOL_MINT)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 7, theirs)]),
+ ME
+ ),
+ /isn’t what Marinade should get/
+ )
+ })
+
+ it('refuses unstaked SOL going to someone else', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 7, SOMEONE_ELSE)]),
+ ME
+ ),
+ /isn’t what Marinade should get/
+ )
+ })
+
+ it('refuses a transfer or an unlimited approval added to it', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest, transferOf(ME, SOMEONE_ELSE, BigInt(1))]), ME),
+ /SOL transfer/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest,
+ unlimitedApproval(associatedTokenAccount(ME, MSOL_MINT), SOMEONE_ELSE, ME),
+ ]),
+ ME
+ ),
+ /moves or approves tokens/
+ )
+ })
+
+ it('refuses the wrong token and the wrong state', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const last = rest.at(-1)!
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ withAccount(last, 1, 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
+ ]),
+ ME
+ ),
+ /isn’t what Marinade should get/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(last, 0, SOMEONE_ELSE)]),
+ ME
+ ),
+ /isn’t what Marinade should get/
+ )
+ })
+
+ it('refuses an amount of zero', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const last = rest.at(-1)!
+ const data = Uint8Array.from(last.data ?? [])
+ data.fill(0, 8)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME),
+ /isn’t what Marinade should get/
+ )
+ })
+})
+
+describe('marinade tokens and listing', () => {
+ it('names mSOL with its mint and decimals', () => {
+ expect(MARINADE_TOKENS).toEqual([
+ { symbol: 'mSOL', id: MSOL_MINT, decimals: 9, name: 'Marinade staked SOL' },
+ ])
+ })
+
+ it('lists as executing on Solana', () => {
+ expect(marinadeListing).toMatchObject({
+ id: 'marinade',
+ family: 'solana',
+ integration: 'executes',
+ })
+ expect(marinadeListing.capability).toMatch(/Native staking and delayed unstake aren’t wired/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/market-context-evm.test.ts b/apps/dapp/lib/__tests__/market-context-evm.test.ts
index 0baa6b4b..463849b2 100644
--- a/apps/dapp/lib/__tests__/market-context-evm.test.ts
+++ b/apps/dapp/lib/__tests__/market-context-evm.test.ts
@@ -26,7 +26,11 @@ describe('the market on EVM chains that share a family', () => {
it('gives each chain only the venues that name it', () => {
const ids = (chain: string): string[] =>
buildMarketContext(chain, {}, 'mainnet').venues.map((v) => v.id)
- expect(ids('avalanche')).toEqual(['on-avalanche'])
- expect(ids('monad')).toEqual(['on-monad'])
+ expect(ids('avalanche')).toContain('on-avalanche')
+ expect(ids('avalanche')).not.toContain('on-monad')
+ expect(ids('avalanche')).not.toContain('aave-monad')
+ expect(ids('monad')).toContain('on-monad')
+ expect(ids('monad')).not.toContain('on-avalanche')
+ expect(ids('monad')).not.toContain('aave-avalanche')
})
})
diff --git a/apps/dapp/lib/__tests__/momentum.live.test.ts b/apps/dapp/lib/__tests__/momentum.live.test.ts
new file mode 100644
index 00000000..d8411727
--- /dev/null
+++ b/apps/dapp/lib/__tests__/momentum.live.test.ts
@@ -0,0 +1,89 @@
+import { fromBase64, normalizeStructTag } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import { tokenType } from '../chains/sui/direct-swap'
+import {
+ MOMENTUM_PACKAGE,
+ MOMENTUM_POOLS,
+ MOMENTUM_VERSION,
+ momentumVenue,
+} from '../chains/sui/momentum'
+
+/**
+ * Read-only against mainnet: everything is built and dry-run, for a public address holding USDC
+ * and SUI whose key nobody here has. When that address empties the swaps fail on balance, so swap
+ * it for another funded one.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const ACCOUNT = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const UPGRADE_CAP = '0x59d16188948e7b89791271c7af78cb877092b8858db44c9166b8af9dee55dcb4'
+const ORIGINAL_PACKAGE = '0x70285592c97965e811e0c6f98dccc3a9c2b4ad854b3594faab9597ada267b860'
+const USDC = tokenType('USDC')
+
+describe.skipIf(SKIP)('Momentum against mainnet', () => {
+ it('still matches its upgrade cap, or the package needs a fresh look', async () => {
+ const { objects } = await suiClient().core.getObjects({
+ objectIds: [UPGRADE_CAP],
+ include: { json: true },
+ })
+ const [cap] = objects as unknown as [{ json?: { package?: string } }]
+ expect(cap.json?.package).toBe(MOMENTUM_PACKAGE)
+ }, 30_000)
+
+ it('still has the version object it pins', async () => {
+ const { objects } = await suiClient().core.getObjects({ objectIds: [MOMENTUM_VERSION] })
+ const [version] = objects as unknown as [{ type?: string }]
+ expect(version.type).toBe(`${ORIGINAL_PACKAGE}::version::Version`)
+ }, 30_000)
+
+ it.each(MOMENTUM_POOLS.map((p) => [`${p.x}/${p.y}`, p] as const))(
+ '%s is still a pool of those coins',
+ async (_name, pool) => {
+ const { objects } = await suiClient().core.getObjects({ objectIds: [pool.id] })
+ const [object] = objects as unknown as [
+ { type?: string; owner?: { Shared?: { initialSharedVersion: string } } },
+ ]
+ expect(object.type).toBe(
+ `${ORIGINAL_PACKAGE}::pool::Pool<${tokenType(pool.x)},${tokenType(pool.y)}>`
+ )
+ expect(object.owner?.Shared?.initialSharedVersion).toBe(String(pool.initialSharedVersion))
+ },
+ 30_000
+ )
+
+ it('quotes USDC for SUI, builds it, passes the check, and the dry run pays near the quote', async () => {
+ const request = { kind: 'swap', amount: '5', tokenIn: 'SUI', tokenOut: 'USDC' } as const
+ const quoted = await momentumVenue.quote?.(request, ACCOUNT)
+ const prepared = await momentumVenue.prepare(request, ACCOUNT)
+ expect(() => momentumVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { balanceChanges: true, effects: true },
+ })
+ expect(result.Transaction?.effects?.status.success).toBe(true)
+ const paid = result.Transaction?.balanceChanges?.find(
+ (c) => normalizeStructTag(c.coinType) === USDC && c.address === ACCOUNT
+ )
+ const got = Number(paid?.amount) / 1e6
+ expect(got).toBeGreaterThan(Number(quoted?.receive?.amount) * 0.995)
+ expect(got).toBeLessThan(Number(quoted?.receive?.amount) * 1.005)
+ }, 60_000)
+
+ it('builds SUI for USDC the other way, and the dry run passes', async () => {
+ const prepared = await momentumVenue.prepare(
+ { kind: 'swap', amount: '5', tokenIn: 'USDC', tokenOut: 'SUI' },
+ ACCOUNT
+ )
+ expect(() => momentumVenue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { effects: true },
+ })
+ expect(result.Transaction?.effects?.status.success).toBe(true)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/momentum.test.ts b/apps/dapp/lib/__tests__/momentum.test.ts
new file mode 100644
index 00000000..d9432fb1
--- /dev/null
+++ b/apps/dapp/lib/__tests__/momentum.test.ts
@@ -0,0 +1,412 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, toBase64 } from '@mysten/sui/utils'
+import { beforeAll, describe, expect, it } from 'vitest'
+
+import type { SwapIo } from '../chains/sui/direct-swap'
+import {
+ createMomentumVenue,
+ MOMENTUM_PACKAGE,
+ MOMENTUM_POOLS,
+ MOMENTUM_VERSION,
+} from '../chains/sui/momentum'
+import { momentumListing } from '../chains/sui/momentum-listing'
+import { ChainActionRefused, type ChainActionRequest } from '../chains/types'
+
+const ME = '0x00000000000000000000000000000000000000000000000000000000000000a1'
+const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead'
+const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01'
+const COIN = '0x00000000000000000000000000000000000000000000000000000000000000c1'
+const GAS = '0x00000000000000000000000000000000000000000000000000000000000000c2'
+const DIGEST = '11111111111111111111111111111111'
+const SCAM = '0x778e2ae6096389cfad3e0b1c6e8080a9287f8792a08d3df1f9025251d820be93::usdt::USDT'
+
+const u64 = (n: string): Uint8Array => bcs.u64().serialize(n).toBytes()
+const u128 = (n: string): Uint8Array => bcs.u128().serialize(n).toBytes()
+
+interface PoolReading {
+ out?: string
+ left?: string
+ after?: string
+ before?: string
+}
+
+/** 10 SUI into 9.3 USDC at a 0.175% fee, moving the price a hair. */
+function fakeIo(reading: PoolReading = {}, prices: Record = {}) {
+ const views: Transaction[] = []
+ const built: Transaction[] = []
+ const io: SwapIo = {
+ view: async (tx) => {
+ views.push(tx)
+ return [
+ [],
+ [u64(reading.out ?? '9300000')],
+ [u64(reading.left ?? '0')],
+ [u128(reading.after ?? '563200040872796795')],
+ [u128(reading.before ?? '563203070563371839')],
+ [u64('1750')],
+ ]
+ },
+ build: async (tx) => {
+ built.push(tx)
+ tx.setGasPrice(1000)
+ tx.setGasBudget(50_000_000)
+ tx.setGasPayment([{ objectId: GAS, version: '1', digest: DIGEST }])
+ return toBase64(await tx.build())
+ },
+ price: async (token) =>
+ token.symbol in prices ? prices[token.symbol] : token.symbol === 'SUI' ? 0.932 : 1,
+ spend: (tx) => tx.objectRef({ objectId: COIN, version: '1', digest: DIGEST }),
+ }
+ return { io, views, built }
+}
+
+const SELL: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'SUI', tokenOut: 'USDC' }
+const BUY: ChainActionRequest = { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'SUI' }
+
+async function preparedBytes(
+ request: ChainActionRequest = SELL,
+ reading: PoolReading = {}
+): Promise {
+ const prepared = await createMomentumVenue(fakeIo(reading).io).prepare(request, ME)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ return prepared.signing.transaction
+}
+
+const venue = createMomentumVenue(fakeIo().io)
+const check = (transaction: string, account = ME) =>
+ venue.assertSafe({ family: 'sui', transaction }, account)
+
+let SOLD = ''
+
+beforeAll(async () => {
+ SOLD = await preparedBytes(SELL)
+})
+
+async function edited(base: string, edit: (tx: Transaction) => void): Promise {
+ const tx = Transaction.from(base)
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+interface JsonArg {
+ Input?: number
+ Result?: number
+ NestedResult?: [number, number]
+}
+interface JsonCall {
+ package: string
+ module: string
+ function: string
+ typeArguments: string[]
+ arguments: JsonArg[]
+}
+interface JsonTx {
+ inputs: { Pure?: { bytes: string }; Object?: { SharedObject?: { objectId: string } } }[]
+ commands: { MoveCall?: JsonCall }[]
+}
+
+async function rewritten(base: string, edit: (data: JsonTx) => void): Promise {
+ const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx
+ edit(data)
+ return toBase64(await Transaction.from(JSON.stringify(data)).build())
+}
+
+function named(data: JsonTx, fn: string): JsonCall {
+ const call = data.commands.find((c) => c.MoveCall?.function === fn)?.MoveCall
+ if (call === undefined) throw new Error(`no ${fn} in this transaction`)
+ return call
+}
+
+function moveCalls(transaction: string) {
+ return Transaction.from(transaction)
+ .getData()
+ .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall] : []))
+}
+
+describe('the Momentum quote', () => {
+ it('reads what the pool gives, and builds nothing', async () => {
+ const { io, views, built } = fakeIo()
+ const quoted = await createMomentumVenue(io).quote?.(SELL, ME)
+ expect(quoted?.receive).toEqual({ amount: '9.3', symbol: 'USDC' })
+ expect(built).toHaveLength(0)
+ expect(views[0]?.getData().commands.map((c) => c.MoveCall?.function)).toEqual([
+ 'compute_swap_result',
+ 'get_state_amount_calculated',
+ 'get_state_amount_specified',
+ 'get_state_sqrt_price',
+ 'sqrt_price',
+ 'swap_fee_rate',
+ ])
+ })
+
+ it('refuses a pair with no pinned pool', async () => {
+ await expect(venue.quote?.({ ...SELL, tokenOut: 'USDsui' }, ME)).rejects.toThrow(
+ /no direct pool/i
+ )
+ })
+
+ it('refuses a token outside the list', async () => {
+ await expect(venue.quote?.({ ...SELL, tokenOut: 'FDUSD' }, ME)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('refuses when the read fails, rather than guessing', async () => {
+ const { io } = fakeIo()
+ io.view = () => Promise.reject(new Error('fullnode down'))
+ await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/couldn't read/i)
+ })
+
+ it('refuses a pool that runs out before the amount is spent', async () => {
+ const { io } = fakeIo({ left: '5000000000', out: '4650000' })
+ await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/part of this amount/i)
+ })
+
+ it('refuses a swap that moves the price more than the limit', async () => {
+ const { io } = fakeIo({ after: '540000000000000000' })
+ await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/move the price/)
+ })
+
+ it('refuses a fill worth far less than the market', async () => {
+ const { io } = fakeIo({}, { SUI: 2 })
+ await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/worse than the market/)
+ })
+
+ it('refuses when no outside price can be read', async () => {
+ const { io } = fakeIo({}, { USDC: undefined })
+ await expect(createMomentumVenue(io).quote?.(SELL, ME)).rejects.toThrow(/live price/)
+ })
+
+ it('refuses an account that is not a Sui address', async () => {
+ await expect(venue.quote?.(SELL, 'not-an-address')).rejects.toThrow(/Sui address/)
+ })
+})
+
+describe('what Momentum prepare builds', () => {
+ it('runs the flash swap in full, with a minimum 0.5% under the quote', () => {
+ const calls = moveCalls(SOLD)
+ expect(calls.map((c) => `${c.module}::${c.function}`)).toEqual([
+ 'trade::flash_swap',
+ 'balance::destroy_zero',
+ 'trade::swap_receipt_debts',
+ 'coin::into_balance',
+ 'balance::zero',
+ 'trade::repay_flash_swap',
+ 'coin::from_balance',
+ 'coin::split',
+ ])
+ expect(calls[0]?.package).toBe(MOMENTUM_PACKAGE)
+ const data = Transaction.from(SOLD).getData()
+ const minimum = calls[7]?.arguments[1]
+ if (minimum?.$kind !== 'Input') throw new Error('minimum is not stated')
+ const bytes = data.inputs[minimum.Input]?.Pure?.bytes ?? ''
+ expect(bcs.u64().parse(fromBase64(bytes))).toBe('9253500')
+ expect(() => check(SOLD)).not.toThrow()
+ })
+
+ it('swaps the other way through the same pool, and passes its own check', async () => {
+ const transaction = await preparedBytes(BUY, { out: '10700000000' })
+ const data = Transaction.from(transaction).getData()
+ const direction = moveCalls(transaction)[0]?.arguments[1]
+ if (direction?.$kind !== 'Input') throw new Error('direction is not stated')
+ const bytes = data.inputs[direction.Input]?.Pure?.bytes ?? ''
+ expect(bcs.bool().parse(fromBase64(bytes))).toBe(false)
+ expect(() => check(transaction)).not.toThrow()
+ })
+
+ it('sends what is left of the input and all the output to the person', () => {
+ const data = Transaction.from(SOLD).getData()
+ const transfer = data.commands.find((c) => c.$kind === 'TransferObjects')?.TransferObjects
+ expect(transfer?.objects).toHaveLength(2)
+ const to = transfer?.address
+ if (to?.$kind !== 'Input') throw new Error('recipient is not stated')
+ expect(bcs.Address.parse(fromBase64(data.inputs[to.Input]?.Pure?.bytes ?? ''))).toBe(ME)
+ })
+
+ it('tells the person the price protection, the fee it read and the risks', async () => {
+ const prepared = await venue.prepare(SELL, ME)
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/more than 0\.5%, the swap cancels/)
+ expect(notes).toMatch(/fee is 0\.175%/)
+ expect(notes).toMatch(/upgrade/)
+ expect(notes).toMatch(/pause/)
+ expect(prepared.receive).toEqual({ amount: '9.3', symbol: 'USDC', approximate: true })
+ expect(prepared.venueId).toBe('momentum')
+ })
+})
+
+describe('the Momentum safety check refuses', () => {
+ it('a different account', () => {
+ expect(() => check(SOLD, THIEF)).toThrow(/different account/)
+ })
+
+ it('a transaction sent by someone else', async () => {
+ const tampered = await edited(SOLD, (tx) => tx.setSender(THIEF))
+ expect(() => check(tampered)).toThrow(/different account/)
+ })
+
+ it('an unknown contract', async () => {
+ const tampered = await edited(SOLD, (tx) => {
+ tx.moveCall({ target: `${FOREIGN}::drain::everything` })
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('the original package id, which is not the one to call', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ named(data, 'flash_swap').package =
+ '0x70285592c97965e811e0c6f98dccc3a9c2b4ad854b3594faab9597ada267b860'
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('a Momentum function this flow never needs', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ named(data, 'flash_swap').function = 'flash_loan'
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('an extra argument', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const call = named(data, 'flash_swap')
+ call.arguments.push(call.arguments[3] as JsonArg)
+ })
+ expect(() => check(tampered)).toThrow(/does not match/)
+ })
+
+ it('payment to someone else', async () => {
+ const tampered = await edited(SOLD, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.transferObjects([coin as never], THIEF)
+ })
+ expect(() => check(tampered)).toThrow(/another address/)
+ })
+
+ it('a framework transfer that hides the recipient', async () => {
+ const tampered = await edited(SOLD, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target: '0x2::transfer::public_transfer',
+ typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'],
+ arguments: [coin as never, tx.pure.address(THIEF)],
+ })
+ })
+ expect(() => check(tampered)).toThrow(/confirmed/)
+ })
+
+ it('no minimum, which is what an unlimited approval would be here', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = named(data, 'split').arguments[1]?.Input
+ const input = at === undefined ? undefined : data.inputs[at]
+ if (input?.Pure === undefined) throw new Error('no minimum')
+ input.Pure.bytes = toBase64(u64('0'))
+ })
+ expect(() => check(tampered)).toThrow(/minimum/)
+ })
+
+ it('a minimum computed elsewhere in the transaction', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ named(data, 'split').arguments[1] = { NestedResult: [2, 0] }
+ })
+ expect(() => check(tampered)).toThrow(/minimum/)
+ })
+
+ it('a minimum taken from a coin other than the output', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ named(data, 'split').arguments[0] = { NestedResult: [0, 0] }
+ })
+ expect(() => check(tampered)).toThrow(/minimum/)
+ })
+
+ it('a swap with no minimum at all', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ data.commands = data.commands.filter((c) => c.MoveCall?.function !== 'split')
+ })
+ expect(() => check(tampered)).toThrow(/minimum/)
+ })
+
+ it('a token outside the pool', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const call = named(data, 'flash_swap')
+ call.typeArguments[1] = SCAM
+ })
+ expect(() => check(tampered)).toThrow(/pool/)
+ })
+
+ it('a pool that is not pinned', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = named(data, 'flash_swap').arguments[0]?.Input
+ const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject
+ if (pool === undefined) throw new Error('no pool')
+ pool.objectId = FOREIGN
+ })
+ expect(() => check(tampered)).toThrow(/pool/)
+ })
+
+ it("one pool's id with another pair's types", async () => {
+ const other = MOMENTUM_POOLS.find((p) => p.x === 'USDT')
+ if (other === undefined) throw new Error('no USDT pool')
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = named(data, 'flash_swap').arguments[0]?.Input
+ const pool = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject
+ if (pool === undefined) throw new Error('no pool')
+ pool.objectId = other.id
+ })
+ expect(() => check(tampered)).toThrow(/pool/)
+ })
+
+ it('an exact-out swap, which the minimum was never worked out for', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = named(data, 'flash_swap').arguments[2]?.Input
+ const input = at === undefined ? undefined : data.inputs[at]
+ if (input?.Pure === undefined) throw new Error('no flag')
+ input.Pure.bytes = toBase64(bcs.bool().serialize(false).toBytes())
+ })
+ expect(() => check(tampered)).toThrow(/does not match/)
+ })
+
+ it('a version object that is not the pinned one', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const at = named(data, 'flash_swap').arguments[6]?.Input
+ const version = at === undefined ? undefined : data.inputs[at]?.Object?.SharedObject
+ if (version === undefined) throw new Error('no version')
+ version.objectId = FOREIGN
+ })
+ expect(() => check(tampered)).toThrow(/does not match/)
+ })
+
+ it('two swaps in one transaction', async () => {
+ const tampered = await rewritten(SOLD, (data) => {
+ const flash = data.commands.find((c) => c.MoveCall?.function === 'flash_swap')
+ if (flash === undefined) throw new Error('no swap')
+ data.commands.push(JSON.parse(JSON.stringify(flash)) as JsonTx['commands'][number])
+ })
+ expect(() => check(tampered)).toThrow(/one swap/)
+ })
+
+ it('a payload for another chain', () => {
+ expect(() => venue.assertSafe({ family: 'solana', transaction: SOLD }, ME)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('bytes that are not a transaction', () => {
+ expect(() => check('not base64 at all!')).toThrow(ChainActionRefused)
+ })
+})
+
+describe('the Momentum pins and listing', () => {
+ it('pins only the pools with real depth for tokens in the table', () => {
+ expect(MOMENTUM_POOLS.map((p) => `${p.x}/${p.y}`).sort()).toEqual(['SUI/USDC', 'USDT/USDC'])
+ expect(MOMENTUM_VERSION).toMatch(/^0x[0-9a-f]{64}$/)
+ })
+
+ it('lists as executing on Sui', () => {
+ expect(momentumListing.id).toBe('momentum')
+ expect(momentumListing.family).toBe('sui')
+ expect(momentumListing.integration).toBe('executes')
+ expect(momentumListing.capability).toMatch(/SUI, USDC and USDT/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/morpho.live.test.ts b/apps/dapp/lib/__tests__/morpho.live.test.ts
new file mode 100644
index 00000000..1f644d93
--- /dev/null
+++ b/apps/dapp/lib/__tests__/morpho.live.test.ts
@@ -0,0 +1,184 @@
+import {
+ encodeAbiParameters,
+ encodeFunctionData,
+ http,
+ keccak256,
+ pad,
+ parseAbi,
+ toHex,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { evmReader } from '../chains/evm/client'
+import {
+ createMorphoVenue,
+ MORPHO_ADAPTER,
+ MORPHO_BLUE_MONAD,
+ MORPHO_REVIEWED_MARKETS,
+ MORPHO_VAULT,
+ readMorphoApy,
+ readMorphoLiquidity,
+ VAULT_V2_FACTORY_MONAD,
+} from '../chains/evm/morpho'
+import { sameAddress } from '../chains/evm/submit'
+
+/**
+ * Whether what's pinned still holds on Monad, read-only. Nothing here signs or sends: a deposit is
+ * tried with `eth_call` and overridden balances, and a withdrawal from a real holder's address,
+ * whose key nobody here has. If that holder leaves, swap it for another.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const RPC = process.env['MONAD_RPC_URL']?.trim() || 'https://rpc.monad.xyz'
+const HOLDER = '0xB936bCd526069e44bB1F3A9dc155d0C86A8e2fD1'
+const NOBODY = '0x000000000000000000000000000000000000dEaD'
+const SLOW_MS = 60_000
+/** Where Circle's USDC keeps balances and allowances, found by probing with an override. */
+const USDC_BALANCE_SLOT = BigInt(9)
+const USDC_ALLOWANCE_SLOT = BigInt(10)
+
+const ABI = parseAbi([
+ 'function asset() view returns (address)',
+ 'function symbol() view returns (string)',
+ 'function decimals() view returns (uint8)',
+ 'function curator() view returns (address)',
+ 'function isVaultV2(address) view returns (bool)',
+ 'function adaptersLength() view returns (uint256)',
+ 'function adapters(uint256) view returns (address)',
+ 'function receiveAssetsGate() view returns (address)',
+ 'function sendAssetsGate() view returns (address)',
+ 'function receiveSharesGate() view returns (address)',
+ 'function sendSharesGate() view returns (address)',
+ 'function marketIdsLength() view returns (uint256)',
+ 'function marketIds(uint256) view returns (bytes32)',
+ 'function idToMarketParams(bytes32) view returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)',
+ 'function deposit(uint256, address) returns (uint256)',
+])
+
+const STREAM_TOKENS = ['XUSD', 'DEUSD', 'SDEUSD']
+
+const slotOf = (key: string, slot: bigint): Hex =>
+ keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [key as Address, slot]))
+const allowanceSlot = (owner: string, spender: string): Hex =>
+ keccak256(
+ encodeAbiParameters(
+ [{ type: 'address' }, { type: 'bytes32' }],
+ [spender as Address, slotOf(owner, USDC_ALLOWANCE_SLOT)]
+ )
+ )
+
+describe.skipIf(SKIP)('Morpho on Monad, live', () => {
+ const reader = evmReader('monad', http(RPC, { timeout: 30_000, retryCount: 2 }))
+ const vault = MORPHO_VAULT.address as Address
+ const read = (address: string, functionName: string, args: unknown[] = []) =>
+ reader.readContract({
+ address: address as Address,
+ abi: ABI,
+ functionName,
+ args,
+ } as never) as Promise
+
+ it('finds code at every pinned address', { timeout: SLOW_MS }, async () => {
+ for (const address of [MORPHO_BLUE_MONAD, VAULT_V2_FACTORY_MONAD, vault, MORPHO_ADAPTER]) {
+ const code = await reader.getCode({ address: address as Address })
+ expect(code?.length ?? 0).toBeGreaterThan(2)
+ }
+ })
+
+ it('is still the reviewed vault', { timeout: SLOW_MS }, async () => {
+ expect(await read(VAULT_V2_FACTORY_MONAD, 'isVaultV2', [vault])).toBe(true)
+ expect(sameAddress((await read(vault, 'asset')) as string, MORPHO_VAULT.asset.id)).toBe(true)
+ expect(await read(vault, 'symbol')).toBe(MORPHO_VAULT.shareSymbol)
+ expect(await read(vault, 'decimals')).toBe(MORPHO_VAULT.shareDecimals)
+ // Named Steakhouse Financial in Morpho's curator registry.
+ expect(
+ sameAddress(
+ (await read(vault, 'curator')) as string,
+ '0x827e86072B06674a077f592A531dcE4590aDeCdB'
+ )
+ ).toBe(true)
+ expect(await read(vault, 'adaptersLength')).toBe(BigInt(1))
+ expect(
+ sameAddress((await read(vault, 'adapters', [BigInt(0)])) as string, MORPHO_ADAPTER)
+ ).toBe(true)
+ })
+
+ it('has no gate that could block a deposit or a withdrawal', { timeout: SLOW_MS }, async () => {
+ for (const gate of [
+ 'receiveAssetsGate',
+ 'sendAssetsGate',
+ 'receiveSharesGate',
+ 'sendSharesGate',
+ ]) {
+ expect(sameAddress((await read(vault, gate)) as string, `0x${'0'.repeat(40)}`)).toBe(true)
+ }
+ })
+
+ it(
+ 'lends only USDC against reviewed collateral, with no Stream token',
+ { timeout: SLOW_MS },
+ async () => {
+ const count = (await read(MORPHO_ADAPTER, 'marketIdsLength')) as bigint
+ const collateral: string[] = []
+ for (let index = BigInt(0); index < count; index += BigInt(1)) {
+ const id = (await read(MORPHO_ADAPTER, 'marketIds', [index])) as string
+ expect(MORPHO_REVIEWED_MARKETS).toContain(id)
+ const params = (await read(MORPHO_BLUE_MONAD, 'idToMarketParams', [id])) as readonly [
+ string,
+ string,
+ ]
+ expect(sameAddress(params[0], MORPHO_VAULT.asset.id)).toBe(true)
+ collateral.push(((await read(params[1], 'symbol')) as string).toUpperCase())
+ }
+ expect(collateral.filter((symbol) => STREAM_TOKENS.includes(symbol))).toEqual([])
+ }
+ )
+
+ it('reads a free amount and a believable rate', { timeout: SLOW_MS }, async () => {
+ expect(await readMorphoLiquidity(reader)).toBeGreaterThan(BigInt(0))
+ const apy = await readMorphoApy(reader)
+ if (apy !== undefined) {
+ expect(apy.basePercent).toBeGreaterThan(0)
+ expect(apy.basePercent).toBeLessThan(50)
+ }
+ })
+
+ it('prepares a deposit for an address that holds USDC', { timeout: SLOW_MS }, async () => {
+ const venue = createMorphoVenue({ reader })
+ // The vault's own cash stands in for a person with USDC. Nothing is signed.
+ const prepared = await venue.prepare({ kind: 'supply', amount: '5', tokenIn: 'USDC' }, vault)
+ expect(() => venue.assertSafe(prepared.signing, vault)).not.toThrow()
+ })
+
+ it(
+ 'accepts the deposit this code builds, with funds overridden',
+ { timeout: SLOW_MS },
+ async () => {
+ const units = BigInt(5_000_000)
+ await expect(
+ reader.call({
+ account: NOBODY,
+ to: vault,
+ data: encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [units, NOBODY] }),
+ stateOverride: [
+ {
+ address: MORPHO_VAULT.asset.id as Address,
+ stateDiff: [
+ { slot: slotOf(NOBODY, USDC_BALANCE_SLOT), value: pad(toHex(units)) },
+ { slot: allowanceSlot(NOBODY, vault), value: pad(toHex(units)) },
+ ],
+ },
+ ],
+ })
+ ).resolves.toBeDefined()
+ }
+ )
+
+ it('prepares and tries a withdrawal for a real holder', { timeout: SLOW_MS }, async () => {
+ const venue = createMorphoVenue({ reader })
+ const prepared = await venue.prepare({ kind: 'withdraw', amount: '5', tokenIn: 'USDC' }, HOLDER)
+ expect(() => venue.assertSafe(prepared.signing, HOLDER)).not.toThrow()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/morpho.test.ts b/apps/dapp/lib/__tests__/morpho.test.ts
new file mode 100644
index 00000000..95b1c7c1
--- /dev/null
+++ b/apps/dapp/lib/__tests__/morpho.test.ts
@@ -0,0 +1,359 @@
+import { decodeFunctionData, encodeAbiParameters, erc20Abi, keccak256, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import type { EvmReaderClient } from '../chains/evm/call'
+import {
+ createMorphoVenue,
+ MORPHO_ADAPTER,
+ MORPHO_REVIEWED_MARKETS,
+ MORPHO_VAULT,
+ readMorphoLiquidity,
+} from '../chains/evm/morpho'
+import { morphoListing } from '../chains/evm/morpho-listing'
+import { MONAD_TOKENS } from '../chains/evm/tokens'
+import { ChainActionRefused, type ChainActionRequest } from '../chains/types'
+import { ACCOUNT, describeVaultRefusals, redeemData, STRANGER } from './erc4626-refusals'
+
+const MONAD_ID = 143
+const SUPPLY: ChainActionRequest = { kind: 'supply', amount: '100', tokenIn: 'USDC' }
+const WITHDRAW: ChainActionRequest = { kind: 'withdraw', amount: '100', tokenIn: 'USDC' }
+
+const units = (usdc: number) => BigInt(usdc) * BigInt(1_000_000)
+const PARAMS = [
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'uint256' },
+] as const
+const LIQUIDITY_DATA = encodeAbiParameters(PARAMS, [
+ MORPHO_VAULT.asset.id as Address,
+ '0x01bFF41798a0BcF287b996046Ca68b395DbC1071',
+ '0xE0b4E42B082F639bc95f4EDf95deEBE7b5Ea94aa',
+ '0x09475a3D6eA8c314c592b1a3799bDE044E2F400F',
+ BigInt('770000000000000000'),
+])
+
+interface Scene {
+ held?: bigint
+ shares?: bigint
+ allowance?: bigint
+ idle?: bigint
+ marketFree?: bigint
+ priceNow?: bigint
+ priceWas?: bigint
+ historyFails?: boolean
+ markets?: readonly string[]
+ adapters?: bigint
+ liquidityFails?: boolean
+ simulationFails?: boolean
+ gated?: boolean
+ sharesFail?: boolean
+}
+
+const WEEK = 604_800
+const NOW_BLOCK = BigInt(100_000_000)
+const NOW_TIME = BigInt(1_800_000_000)
+const PRICE = BigInt('1000000000000')
+
+function fakeReader(scene: Scene = {}) {
+ const calls: { to?: string; data?: string }[] = []
+ const reader = {
+ getBlock: async (input?: { blockNumber?: bigint }) => {
+ if (input?.blockNumber !== undefined && scene.historyFails) throw new Error('no history')
+ return input?.blockNumber === undefined
+ ? { number: NOW_BLOCK, timestamp: NOW_TIME }
+ : { number: input.blockNumber, timestamp: NOW_TIME - BigInt(WEEK) }
+ },
+ call: async (input: { to?: string; data?: string }) => {
+ calls.push(input)
+ if (scene.simulationFails) throw new Error('reverted')
+ return {}
+ },
+ readContract: async ({
+ address,
+ functionName,
+ args,
+ blockNumber,
+ }: {
+ address: string
+ functionName: string
+ args?: readonly unknown[]
+ blockNumber?: bigint
+ }) => {
+ const isVault = address.toLowerCase() === MORPHO_VAULT.address.toLowerCase()
+ switch (functionName) {
+ case 'balanceOf':
+ if (isVault && scene.sharesFail) throw new Error('no reading')
+ if (isVault) return scene.shares ?? BigInt(0)
+ return (args?.[0] as string).toLowerCase() === MORPHO_VAULT.address.toLowerCase()
+ ? (scene.idle ?? units(50_000))
+ : (scene.held ?? units(1_000))
+ case 'allowance':
+ return scene.allowance ?? BigInt(0)
+ case 'totalAssets':
+ return units(200_000)
+ case 'previewDeposit':
+ return BigInt('97000000000000000000')
+ case 'convertToAssets':
+ if (blockNumber === undefined) return args?.[0] as bigint
+ if (blockNumber !== NOW_BLOCK && scene.historyFails) throw new Error('no history')
+ return blockNumber === NOW_BLOCK
+ ? (scene.priceNow ?? (PRICE * BigInt(1001)) / BigInt(1000))
+ : (scene.priceWas ?? PRICE)
+ case 'adaptersLength':
+ return scene.adapters ?? BigInt(1)
+ case 'adapters':
+ return MORPHO_ADAPTER
+ case 'marketIdsLength':
+ return BigInt((scene.markets ?? MORPHO_REVIEWED_MARKETS).length)
+ case 'marketIds':
+ return (scene.markets ?? MORPHO_REVIEWED_MARKETS)[Number(args?.[0])]
+ case 'liquidityAdapter':
+ if (scene.liquidityFails) throw new Error('no reading')
+ return MORPHO_ADAPTER
+ case 'liquidityData':
+ return LIQUIDITY_DATA
+ case 'market':
+ return [
+ units(100_000),
+ units(100_000),
+ units(100_000) - (scene.marketFree ?? units(10_000)),
+ BigInt(0),
+ BigInt(0),
+ BigInt(0),
+ ]
+ case 'position':
+ return [units(30_000), BigInt(0), BigInt(0)]
+ case 'canSendShares':
+ return scene.gated !== true
+ case 'canReceiveAssets':
+ return true
+ default:
+ throw new Error(`Unexpected read: ${functionName}`)
+ }
+ },
+ multicall: async ({
+ contracts,
+ }: {
+ contracts: { address: string; functionName: string; args?: readonly unknown[] }[]
+ }): Promise =>
+ Promise.all(contracts.map((contract) => reader.readContract(contract))),
+ }
+ return { reader: reader as unknown as EvmReaderClient, calls }
+}
+
+function venueWith(scene: Scene = {}) {
+ const { reader, calls } = fakeReader(scene)
+ return { venue: createMorphoVenue({ reader }), calls }
+}
+
+describeVaultRefusals(createMorphoVenue({ reader: fakeReader().reader }), MORPHO_VAULT, MONAD_ID, [
+ // Hyperithm USDC Apex, SharpByte USDC High Yield and August USDC V2: reviewed and left out.
+ '0x78999cc96d2Ba0341588C60CcB0E91c6C33CF371',
+ '0xf57E9898575fab80b0f2aCDe5443276BeC8C542D',
+ '0x80017bF0f793EBbE9679Cd61ff0e395B62CAbB59',
+ // The older Steakhouse vault of the same name.
+ '0x802c91d807A8DaCA257c4708ab264B6520964e44',
+])
+
+describe('Morpho on Monad', () => {
+ it('pins the USDC in the chain table', () => {
+ expect(MONAD_TOKENS.find((t) => t.symbol === 'USDC')).toEqual(MORPHO_VAULT.asset)
+ })
+
+ it('lists as executing on Monad only', () => {
+ expect(morphoListing).toMatchObject({
+ id: 'morpho',
+ family: 'evm',
+ chains: ['Monad'],
+ integration: 'executes',
+ })
+ })
+
+ it('builds a deposit crediting the person, with an exact approval', async () => {
+ const { venue } = venueWith()
+ const prepared = await venue.prepare(SUPPLY, ACCOUNT)
+ expect(prepared.venueId).toBe('morpho')
+ expect(prepared.receive).toEqual({ amount: '97', symbol: 'bbqUSDC', approximate: true })
+ if (prepared.signing.family !== 'evm') throw new Error('Expected an EVM payload.')
+ expect(prepared.signing.to).toBe(MORPHO_VAULT.address)
+ const approval = decodeFunctionData({
+ abi: erc20Abi,
+ data: prepared.signing.approval?.data as `0x${string}`,
+ })
+ expect(approval.functionName).toBe('approve')
+ expect(approval.args).toEqual([MORPHO_VAULT.address, units(100)])
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ })
+
+ it('names the curator and the risk on the card', async () => {
+ const { venue } = venueWith()
+ const { summary, notes } = await venue.prepare(SUPPLY, ACCOUNT)
+ expect(summary).toContain('Steakhouse Financial')
+ expect(notes.join(' ')).toMatch(/XAUt0, WMON, WETH and WBTC/)
+ expect(notes.join(' ')).toMatch(/\$250,000/)
+ expect(notes.join(' ')).toMatch(/About 60,000 of the 200,000 USDC/)
+ })
+
+ it('tries the call when the allowance is already there', async () => {
+ const { venue, calls } = venueWith({ allowance: units(100) })
+ const prepared = await venue.prepare(SUPPLY, ACCOUNT)
+ expect(calls).toHaveLength(1)
+ expect(prepared.signing).not.toHaveProperty('approval')
+ })
+
+ it('reads the rate from a week of share price, with no reward part', async () => {
+ const { venue } = venueWith({ priceNow: BigInt('1000500000000') })
+ const { apy } = await venue.prepare(SUPPLY, ACCOUNT)
+ expect(apy?.basePercent).toBeCloseTo((Math.pow(1.0005, 365 / 7) - 1) * 100, 6)
+ expect(apy?.rewardPercent).toBeUndefined()
+ await expect(venue.quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({
+ apyPercent: apy?.basePercent,
+ })
+ })
+
+ it('shows no rate when the chain has no history, and still builds', async () => {
+ const { venue } = venueWith({ historyFails: true })
+ const prepared = await venue.prepare(SUPPLY, ACCOUNT)
+ expect(prepared).not.toHaveProperty('apy')
+ await expect(venue.quote?.(SUPPLY, ACCOUNT)).resolves.toEqual({})
+ })
+
+ it('shows no rate when the price has not moved', async () => {
+ const { venue } = venueWith({ priceNow: PRICE })
+ expect(await venue.prepare(SUPPLY, ACCOUNT)).not.toHaveProperty('apy')
+ })
+
+ it('refuses a deposit into a vault that lost value this week', async () => {
+ const { venue } = venueWith({ priceNow: PRICE - BigInt(1) })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/lost value/)
+ await expect(venue.quote?.(SUPPLY, ACCOUNT)).rejects.toThrow(/lost value/)
+ })
+
+ it('refuses a vault lending to a market that was not reviewed', async () => {
+ const { venue } = venueWith({ markets: [...MORPHO_REVIEWED_MARKETS, keccak256('0x01')] })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/added a market/)
+ })
+
+ it('refuses a vault with another adapter', async () => {
+ const { venue } = venueWith({ adapters: BigInt(2) })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/how it lends/)
+ })
+
+ it('refuses a deposit into a vault with almost nothing free', async () => {
+ const { venue } = venueWith({ idle: units(1_000), marketFree: units(1_000) })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/won't add to it/)
+ })
+
+ it('refuses a deposit when the vault cannot be read', async () => {
+ const { venue } = venueWith({ liquidityFails: true })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/couldn't read/)
+ })
+
+ it('refuses more than the wallet holds', async () => {
+ const { venue } = venueWith({ held: units(50) })
+ await expect(venue.prepare(SUPPLY, ACCOUNT)).rejects.toThrow(/You hold 50 USDC/)
+ })
+
+ it('counts cash plus what the liquidity market can give, up to the position', async () => {
+ const { reader } = fakeReader({ idle: units(5), marketFree: units(40_000) })
+ await expect(readMorphoLiquidity(reader)).resolves.toBe(units(30_005))
+ })
+
+ it('builds a withdrawal to the person from the person, with no approval', async () => {
+ const { venue, calls } = venueWith({ shares: units(500) })
+ const prepared = await venue.prepare(WITHDRAW, ACCOUNT)
+ expect(prepared.kind).toBe('withdraw')
+ expect(prepared.pay).toEqual({ amount: '100', symbol: 'USDC' })
+ expect(calls).toHaveLength(1)
+ expect(prepared.signing).not.toHaveProperty('approval')
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(prepared.signing, STRANGER)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a withdrawal with no position, or more than the position', async () => {
+ await expect(venueWith().venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(/You have no USDC/)
+ await expect(venueWith({ shares: units(50) }).venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(
+ /about 50 USDC/
+ )
+ })
+
+ it('says plainly when the vault cannot pay out that much now', async () => {
+ const { venue } = venueWith({
+ shares: units(5_000),
+ idle: units(10),
+ marketFree: units(20),
+ })
+ const request = { ...WITHDRAW, amount: '1000' }
+ await expect(venue.prepare(request, ACCOUNT)).rejects.toThrow(/can pay out about 30 USDC/)
+ })
+
+ it('leaves the way out open when liquidity cannot be read, and lets the chain decide', async () => {
+ const { venue, calls } = venueWith({ shares: units(500), liquidityFails: true })
+ await expect(venue.prepare(WITHDRAW, ACCOUNT)).resolves.toMatchObject({ kind: 'withdraw' })
+ expect(calls).toHaveLength(1)
+ })
+
+ it('refuses a withdrawal that fails on the chain', async () => {
+ const { venue } = venueWith({ shares: units(500), simulationFails: true })
+ await expect(venue.prepare(WITHDRAW, ACCOUNT)).rejects.toThrow(/would fail on the chain/)
+ })
+})
+
+describe('Morpho exits', () => {
+ const WHOLE: ChainActionRequest = { ...WITHDRAW, wholePosition: true }
+
+ it('shows what the shares are worth now, in the asset', async () => {
+ const { venue } = venueWith({ shares: units(500) })
+ await expect(venue.positions?.(ACCOUNT)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '500' },
+ ])
+ })
+
+ it('shows nothing when there are no shares', async () => {
+ await expect(venueWith().venue.positions?.(ACCOUNT)).resolves.toEqual([])
+ })
+
+ it('throws when it can not read, rather than saying nothing is there', async () => {
+ const { venue } = venueWith({ sharesFail: true })
+ await expect(venue.positions?.(ACCOUNT)).rejects.toThrow()
+ })
+
+ it('redeems every share for all of it, to and from the person', async () => {
+ const { venue } = venueWith({ shares: units(500) })
+ const prepared = await venue.prepare(WHOLE, ACCOUNT)
+ expect(prepared.signing).toMatchObject({ data: redeemData(units(500)) })
+ expect(prepared.pay).toEqual({ amount: '500', symbol: 'USDC' })
+ expect(prepared.signing).not.toHaveProperty('approval')
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(() => venue.assertSafe(prepared.signing, STRANGER)).toThrow(ChainActionRefused)
+ })
+
+ it('takes all of it even where interest has passed the figure read', async () => {
+ const { venue } = venueWith({ shares: units(100) + BigInt(7) })
+ const prepared = await venue.prepare(WHOLE, ACCOUNT)
+ expect(prepared.signing).toMatchObject({ data: redeemData(units(100) + BigInt(7)) })
+ })
+
+ it('refuses all of it when the vault can not pay that much now', async () => {
+ const { venue } = venueWith({ shares: units(50_000), idle: units(10), marketFree: units(20) })
+ await expect(venue.prepare(WHOLE, ACCOUNT)).rejects.toThrow(/can pay out about 30 USDC/)
+ })
+
+ it('refuses all of it when there is nothing there', async () => {
+ await expect(venueWith().venue.prepare(WHOLE, ACCOUNT)).rejects.toThrow(/You have no USDC/)
+ })
+
+ it('says so when the curator has closed the gate', async () => {
+ const { venue } = venueWith({ shares: units(500), gated: true })
+ const asked = venue.prepare(WHOLE, ACCOUNT)
+ await expect(asked).rejects.toThrow(/closed withdrawals/)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('tells a failed exit apart from a missing fee, in plain words', async () => {
+ const { venue } = venueWith({ shares: units(500), simulationFails: true })
+ await expect(venue.prepare(WHOLE, ACCOUNT)).rejects.toThrow(/position is still there/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/navi-withdraw.live.test.ts b/apps/dapp/lib/__tests__/navi-withdraw.live.test.ts
new file mode 100644
index 00000000..2f4ab528
--- /dev/null
+++ b/apps/dapp/lib/__tests__/navi-withdraw.live.test.ts
@@ -0,0 +1,234 @@
+import { normalizeStructTag } from '@mysten/sui/utils'
+import { getConfig, getPools } from '@naviprotocol/lending'
+import { describe, expect, it } from 'vitest'
+
+import { dryRunChange } from '../chains/sui/lending-calls'
+import { naviVenue } from '../chains/sui/navi'
+import {
+ NAVI_ASSETS,
+ NAVI_FEEDS,
+ NAVI_INCENTIVE_V2,
+ NAVI_INCENTIVE_V3,
+ NAVI_ORACLE_CONFIG,
+ NAVI_ORACLE_PACKAGE,
+ NAVI_PRICE_ORACLE,
+ NAVI_STORAGE,
+ NAVI_SUPRA_HOLDER,
+ NAVI_SWITCHBOARD,
+ NAVI_UI_GETTER,
+ NAVI_USDC_ASSET_ID,
+ NAVI_USDC_FEED,
+ NAVI_USDC_POOL,
+ NAVI_USDC_PYTH_OBJECT,
+ naviChainReader,
+ readNaviPositions,
+} from '../chains/sui/navi-withdraw'
+import { SUI_TOKENS } from '../chains/sui/tokens'
+
+/**
+ * Real transactions built and dry-run against mainnet, for public addresses whose keys
+ * nobody here has. Nothing signs or sends. If a holder moves its funds these fail, so
+ * swap it for another wallet with the position its comment describes.
+ */
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const USDC_ONLY = '0xb26c1e93bed3707be8bb037aa36e9d2d4f640dbd4b470a214ab3e1b59983a40a'
+/** Owes USDC and USDsui against several assets. */
+const BORROWER = '0x4e677759eda54f1caee2b93d52c2ebb74f60d66e843b91429ea24fb4ba29ae70'
+/** Owes a little USDC against USDC, and holds enough USDC to clear it. */
+const SMALL_LOAN = '0xf5495ebdb1936083adf3316f6b9500d578f106e95008ea7337c7bfacfa10d219'
+/** Owes a lot of USDC against SUI and haSUI, and holds no USDC. */
+const SUI_COLLATERAL = '0x82f39aef0914182f43682d3af19a4f5f0589e20a8a3a88a482934751b51a7f10'
+/** Owes USDC against plenty of SUI, with room to borrow more. */
+const ROOMY = '0xa728219749ee5fdf2ee0cbd13bab1b6ce9f9beb81662a405c97c9f682a83960c'
+const NEVER_USED = '0x0000000000000000000000000000000000000000000000000000000000001234'
+const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id ?? ''
+
+describe.skipIf(SKIP)('NAVI against mainnet', () => {
+ it("still matches NAVI's published config, or the pins need a fresh look", async () => {
+ const config = await getConfig({ env: 'prod' })
+ expect(config.storage).toBe(NAVI_STORAGE)
+ expect(config.incentiveV2).toBe(NAVI_INCENTIVE_V2)
+ expect(config.incentiveV3).toBe(NAVI_INCENTIVE_V3)
+ expect(config.priceOracle).toBe(NAVI_PRICE_ORACLE)
+ expect(config.oracle.packageId).toBe(NAVI_ORACLE_PACKAGE)
+ expect(config.oracle.oracleConfig).toBe(NAVI_ORACLE_CONFIG)
+ expect(config.oracle.supraOracleHolder).toBe(NAVI_SUPRA_HOLDER)
+ expect(config.oracle.switchboardAggregator).toBe(NAVI_SWITCHBOARD)
+ expect(config.oracle.updateFunction).toBe('update_single_price_v3')
+ expect(config.uiGetter).toBe(NAVI_UI_GETTER)
+ const feed = config.oracle.feeds.find((f) => f.oracleId === NAVI_USDC_ASSET_ID)
+ expect(feed?.feedId).toBe(NAVI_USDC_FEED)
+ expect(feed?.pythPriceInfoObject).toBe(NAVI_USDC_PYTH_OBJECT)
+ expect(NAVI_FEEDS).toEqual(
+ config.oracle.feeds.map((f) => [f.oracleId, f.feedId, f.pythPriceInfoObject])
+ )
+ }, 30_000)
+
+ it("still has the pinned pools in NAVI's list", async () => {
+ const pools = await getPools({ env: 'prod' })
+ const pool = pools.find((p) => p.id === NAVI_USDC_ASSET_ID)
+ expect(pool?.contract.pool).toBe(NAVI_USDC_POOL)
+ expect(pool?.suiCoinType).toBe(USDC)
+ for (const asset of NAVI_ASSETS) {
+ const listed = pools.find((p) => p.id === asset.assetId)
+ expect(listed?.contract.pool).toBe(asset.pool)
+ const type = SUI_TOKENS.find((t) => t.symbol === asset.symbol)?.id
+ expect(normalizeStructTag(listed?.suiCoinType ?? '')).toBe(normalizeStructTag(type ?? ''))
+ }
+ }, 30_000)
+
+ it('reads positions from the chain, with interest, and none for an unused wallet', async () => {
+ const usdcOnly = await readNaviPositions(USDC_ONLY)
+ expect(usdcOnly.map((p) => [p.slot, p.symbol])).toEqual([['supplied', 'USDC']])
+ expect(Number(usdcOnly[0]?.amount)).toBeGreaterThan(0)
+
+ const held = await readNaviPositions(BORROWER)
+ expect(held.find((p) => p.slot === 'borrowed' && p.symbol === 'USDC')).toBeDefined()
+ expect(held.find((p) => p.slot === 'borrowed' && p.symbol === 'USDsui')).toBeDefined()
+
+ expect(await readNaviPositions(NEVER_USED)).toEqual([])
+ }, 30_000)
+
+ it('reads a position in dollars from the contract, with its assets priced', async () => {
+ const held = await naviChainReader.account(BORROWER)
+ const standing = await naviChainReader.standing(BORROWER, [
+ ...new Set([...held.supplied.keys(), ...held.owed.keys()]),
+ ])
+ expect(standing.owedUsd).toBeGreaterThan(0)
+ expect(standing.limitUsd).toBeGreaterThan(standing.owedUsd)
+ expect(standing.liquidationUsd).toBeGreaterThan(standing.limitUsd)
+ }, 30_000)
+
+ it('builds a borrow with no key and no price service, and pays what was asked', async () => {
+ const prepared = await naviVenue.prepare(
+ { kind: 'borrow', amount: '0.5', tokenIn: 'USDC' },
+ ROOMY
+ )
+ expect(() => naviVenue.assertSafe(prepared.signing, ROOMY)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(await dryRunChange(prepared.signing.transaction, USDC, ROOMY, 'Dry run')).toBe(
+ BigInt(500_000)
+ )
+ expect(prepared.notes.join(' ')).toMatch(/can be liquidated/)
+ expect(prepared.notes.join(' ')).toMatch(/fee on this borrow/)
+ }, 60_000)
+
+ it('borrows SUI through the call that can unstake it, net of the network fee', async () => {
+ const prepared = await naviVenue.prepare(
+ { kind: 'borrow', amount: '0.1', tokenIn: 'SUI' },
+ ROOMY
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(
+ await dryRunChange(prepared.signing.transaction, '0x2::sui::SUI', ROOMY, 'Dry run')
+ ).toBe(BigInt(100_000_000))
+ }, 60_000)
+
+ it('withdraws collateral a loan can spare, and says what is left', async () => {
+ const prepared = await naviVenue.prepare(
+ { kind: 'withdraw', amount: '1', tokenIn: 'SUI' },
+ ROOMY
+ )
+ expect(() => naviVenue.assertSafe(prepared.signing, ROOMY)).not.toThrow()
+ expect(prepared.notes.join(' ')).toMatch(/backs your loan/)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(
+ await dryRunChange(prepared.signing.transaction, '0x2::sui::SUI', ROOMY, 'Dry run')
+ ).toBe(BigInt(1_000_000_000))
+ }, 60_000)
+
+ it('refuses a borrow past what the collateral can back', async () => {
+ await expect(
+ naviVenue.prepare({ kind: 'borrow', amount: '100000000', tokenIn: 'USDC' }, SUI_COLLATERAL)
+ ).rejects.toThrow(/can back about|can't back any more/)
+ }, 60_000)
+
+ it('refuses a borrow with nothing to borrow against', async () => {
+ await expect(
+ naviVenue.prepare({ kind: 'borrow', amount: '1', tokenIn: 'USDC' }, NEVER_USED)
+ ).rejects.toThrow(/no collateral on NAVI/)
+ }, 60_000)
+
+ it('repays part of a loan, taking exactly what was asked', async () => {
+ const prepared = await naviVenue.prepare(
+ { kind: 'repay', amount: '0.01', tokenIn: 'USDC' },
+ SMALL_LOAN
+ )
+ expect(() => naviVenue.assertSafe(prepared.signing, SMALL_LOAN)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(await dryRunChange(prepared.signing.transaction, USDC, SMALL_LOAN, 'Dry run')).toBe(
+ BigInt(-10_000)
+ )
+ }, 60_000)
+
+ it('clears a whole loan, interest included, and keeps the extra it offered', async () => {
+ const owed = (await readNaviPositions(SMALL_LOAN)).find(
+ (p) => p.slot === 'borrowed' && p.symbol === 'USDC'
+ )
+ if (owed === undefined) throw new Error('this wallet no longer owes USDC')
+ const prepared = await naviVenue.prepare(
+ { kind: 'repay', amount: owed.amount, tokenIn: 'USDC', wholePosition: true },
+ SMALL_LOAN
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const paid = await dryRunChange(prepared.signing.transaction, USDC, SMALL_LOAN, 'Dry run')
+ const owedUnits = Math.round(Number(owed.amount) * 1e6)
+ // The figure read is within a few units of what the contract charged, and the extra came back.
+ expect(Math.abs(Number(paid) + owedUnits)).toBeLessThanOrEqual(3)
+ }, 60_000)
+
+ it('refuses a whole repay the wallet cannot cover', async () => {
+ const owed = (await readNaviPositions(SUI_COLLATERAL)).find(
+ (p) => p.slot === 'borrowed' && p.symbol === 'USDC'
+ )
+ if (owed === undefined) throw new Error('this wallet no longer owes USDC')
+ await expect(
+ naviVenue.prepare(
+ { kind: 'repay', amount: owed.amount, tokenIn: 'USDC', wholePosition: true },
+ SUI_COLLATERAL
+ )
+ ).rejects.toThrow(/which is less than the .* you owe/)
+ }, 60_000)
+
+ it('refuses taking out what a loan depends on, and says so', async () => {
+ const held = await readNaviPositions(SUI_COLLATERAL)
+ const sui = held.find((p) => p.slot === 'supplied' && p.symbol === 'SUI')
+ if (sui === undefined) throw new Error('this wallet no longer supplies SUI')
+ await expect(
+ naviVenue.prepare(
+ { kind: 'withdraw', amount: sui.amount, tokenIn: 'SUI', wholePosition: true },
+ SUI_COLLATERAL
+ )
+ ).rejects.toThrow(/Your loan on NAVI depends on this SUI/)
+ }, 60_000)
+
+ it('builds a withdrawal from pinned ids, passes its check and pays what was asked', async () => {
+ const prepared = await naviVenue.prepare(
+ { kind: 'withdraw', amount: '0.001', tokenIn: 'USDC' },
+ USDC_ONLY
+ )
+ expect(() => naviVenue.assertSafe(prepared.signing, USDC_ONLY)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const received = await dryRunChange(prepared.signing.transaction, USDC, USDC_ONLY, 'Dry run')
+ expect(Number(received)).toBe(1000)
+ }, 60_000)
+
+ it('empties a position on a whole withdraw, past the figure read', async () => {
+ const [held] = await readNaviPositions(USDC_ONLY)
+ if (held === undefined) throw new Error('this wallet no longer supplies USDC')
+ const prepared = await naviVenue.prepare(
+ { kind: 'withdraw', amount: held.amount, tokenIn: 'USDC', wholePosition: true },
+ USDC_ONLY
+ )
+ expect(() => naviVenue.assertSafe(prepared.signing, USDC_ONLY)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const received = await dryRunChange(prepared.signing.transaction, USDC, USDC_ONLY, 'Dry run')
+ expect(Number(received)).toBeGreaterThanOrEqual(Math.floor(Number(held.amount) * 1e6))
+ }, 60_000)
+
+ it('says what the wallet holds when asked for more', async () => {
+ await expect(
+ naviVenue.prepare({ kind: 'withdraw', amount: '1000', tokenIn: 'USDC' }, USDC_ONLY)
+ ).rejects.toThrow(/You have about .* USDC in NAVI/)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/navi-withdraw.test.ts b/apps/dapp/lib/__tests__/navi-withdraw.test.ts
new file mode 100644
index 00000000..647471d4
--- /dev/null
+++ b/apps/dapp/lib/__tests__/navi-withdraw.test.ts
@@ -0,0 +1,454 @@
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, toBase64 } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { CAPTURED_NAVI_DEPOSIT, CAPTURED_SENDER } from '../chains/sui/captured-transactions'
+import { createNaviVenue, naviVenue } from '../chains/sui/navi'
+import {
+ naviBorrowTransaction,
+ naviRepayTransaction,
+ naviWithdrawTransaction,
+ NAVI_USDC_ASSET_ID,
+ prepareNaviBorrow,
+ prepareNaviRepay,
+ prepareNaviWithdraw,
+ readNaviPositions,
+ wholeWithdrawAmount,
+ type NaviAccount,
+ type NaviReader,
+} from '../chains/sui/navi-withdraw'
+import { ChainActionRefused, type ChainActionRequest } from '../chains/types'
+import {
+ callNamed,
+ inputOf,
+ NAVI_BORROW,
+ NAVI_BORROW_SENDER,
+ NAVI_REPAY,
+ NAVI_REPAY_SENDER,
+ NAVI_WITHDRAW,
+ NAVI_WITHDRAW_SENDER,
+ OTHER_ADDRESS,
+ OTHER_OBJECT,
+ pointAt,
+ tampered,
+} from './sui-lending-captured'
+
+const UNLISTED = '0x0000000000000000000000000000000000000000000000000000000000000bad::fake::FAKE'
+const ME = '0x0000000000000000000000000000000000000000000000000000000000000abc'
+const SUI_ASSET = 0
+const UNKNOWN_ASSET = 200
+
+function check(transaction: string, account = NAVI_WITHDRAW_SENDER): void {
+ naviVenue.assertSafe({ family: 'sui', transaction }, account)
+}
+
+function action(kind: ChainActionRequest['kind'], amount = '1', tokenIn = 'USDC') {
+ return { kind, amount, tokenIn } as ChainActionRequest
+}
+
+const RESERVES = new Map([
+ [NAVI_USDC_ASSET_ID, { ltv: 0.8, liquidationThreshold: 0.85, borrowAprPercent: 6 }],
+ [SUI_ASSET, { ltv: 0.75, liquidationThreshold: 0.8, borrowAprPercent: 3 }],
+])
+
+function account(supplied: [number, bigint][], owed: [number, bigint][] = []): NaviAccount {
+ return { supplied: new Map(supplied), owed: new Map(owed), reserves: RESERVES }
+}
+
+function reader(over: Partial = {}, held = account([])): NaviReader {
+ return {
+ account: async () => held,
+ standing: async () => ({ liquidationUsd: 85, limitUsd: 80, owedUsd: 0 }),
+ priceUsd: async () => 1,
+ borrowFeePercent: async () => 0,
+ walletBalance: async () => BigInt(5_000_000),
+ ...over,
+ }
+}
+
+const USDC_10 = BigInt(10_000_000)
+const holdsUsdc = account([[NAVI_USDC_ASSET_ID, USDC_10]])
+
+/** Every literal a call was given, as the number it encodes. */
+function amountOf(tx: Transaction, name: string, index: number): bigint {
+ const data = tx.getData()
+ const call = data.commands.find(
+ (c) => c.$kind === 'MoveCall' && c.MoveCall.function === name
+ )?.MoveCall
+ const argument = call?.arguments[index]
+ const input = argument?.$kind === 'Input' ? data.inputs[argument.Input] : undefined
+ if (input?.$kind !== 'Pure') throw new Error(`no literal at ${index} of ${name}`)
+ const bytes = fromBase64(input.Pure.bytes)
+ return new DataView(bytes.buffer, bytes.byteOffset).getBigUint64(0, true)
+}
+
+describe('what the venue really built passes', () => {
+ it('a withdrawal', () => {
+ expect(() => check(NAVI_WITHDRAW)).not.toThrow()
+ })
+
+ it('a deposit, unchanged by the new checks', () => {
+ expect(() =>
+ naviVenue.assertSafe({ family: 'sui', transaction: CAPTURED_NAVI_DEPOSIT }, CAPTURED_SENDER)
+ ).not.toThrow()
+ })
+
+ it('a borrow and a repay', () => {
+ expect(() => check(NAVI_BORROW, NAVI_BORROW_SENDER)).not.toThrow()
+ expect(() => check(NAVI_REPAY, NAVI_REPAY_SENDER)).not.toThrow()
+ })
+})
+
+describe('NAVI refuses a withdrawal transaction that', () => {
+ it('another account would sign', () => {
+ expect(() => check(NAVI_WITHDRAW, OTHER_ADDRESS)).toThrow(/different account/)
+ })
+
+ it('calls a contract that is not confirmed', async () => {
+ const tx = await tampered(NAVI_WITHDRAW, (t) => {
+ callNamed(t, 'entry_withdraw').package = OTHER_OBJECT
+ })
+ expect(() => check(tx)).toThrow(/haven't confirmed for NAVI/)
+ })
+
+ it.each(['withdraw_v2', 'borrow_v2', 'entry_repay_on_behalf_of_user'])(
+ 'calls %s, which this venue never uses',
+ async (name) => {
+ const tx = await tampered(NAVI_WITHDRAW, (t) => {
+ callNamed(t, 'entry_withdraw').function = name
+ })
+ expect(() => check(tx)).toThrow(/haven't confirmed for NAVI/)
+ }
+ )
+
+ it('passes an extra argument', async () => {
+ const tx = await tampered(NAVI_WITHDRAW, (t) => {
+ const call = callNamed(t, 'entry_withdraw')
+ const extra = call.arguments[0]
+ if (extra === undefined) throw new Error('no argument')
+ call.arguments.push(extra)
+ })
+ expect(() => check(tx)).toThrow(/wrong arguments/)
+ })
+
+ it('takes an extra step of a kind it never needs', async () => {
+ const base = Transaction.from(NAVI_WITHDRAW)
+ base.makeMoveVec({ type: 'u64', elements: [] })
+ const tx = toBase64(await base.build())
+ expect(() => check(tx)).toThrow(/does more than NAVI needs/)
+ })
+
+ it('pays anything to someone else through the framework', async () => {
+ const base = Transaction.from(NAVI_WITHDRAW)
+ base.moveCall({
+ target: '0x2::transfer::public_transfer',
+ typeArguments: ['0x2::sui::SUI'],
+ arguments: [base.gas, base.pure.address(OTHER_ADDRESS)],
+ })
+ const tx = toBase64(await base.build())
+ expect(() => check(tx)).toThrow(ChainActionRefused)
+ })
+
+ it('pays anything to someone else with a transfer', async () => {
+ const base = Transaction.from(NAVI_WITHDRAW)
+ base.transferObjects([base.gas], OTHER_ADDRESS)
+ const tx = toBase64(await base.build())
+ expect(() => check(tx)).toThrow(/another address/)
+ })
+
+ it('uses a token outside the list', async () => {
+ const tx = await tampered(NAVI_WITHDRAW, (t) => {
+ callNamed(t, 'entry_withdraw').typeArguments = [UNLISTED]
+ })
+ expect(() => check(tx)).toThrow(/token NAVI isn't confirmed for/)
+ })
+
+ it.each([
+ ['price oracle', 1],
+ ['storage', 2],
+ ['pool', 3],
+ ['first incentive', 6],
+ ['second incentive', 7],
+ ])('points at another %s', async (_name, index) => {
+ const tx = await tampered(NAVI_WITHDRAW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'entry_withdraw'), index), OTHER_OBJECT)
+ })
+ expect(() => check(tx)).toThrow(/object NAVI doesn't use/)
+ })
+
+ it('names another asset', async () => {
+ const tx = await tampered(NAVI_WITHDRAW, (t) => {
+ const input = inputOf(t, callNamed(t, 'entry_withdraw'), 4)
+ if (input.Pure === undefined) throw new Error('not a literal')
+ input.Pure.bytes = 'AQ=='
+ })
+ expect(() => check(tx)).toThrow(/value NAVI doesn't use/)
+ })
+
+ it('updates a price from another feed or object', async () => {
+ const feed = await tampered(NAVI_WITHDRAW, (t) => {
+ const input = inputOf(t, callNamed(t, 'update_single_price_v3'), 6)
+ if (input.Pure === undefined) throw new Error('not a literal')
+ input.Pure.bytes = toBase64(new Uint8Array(32))
+ })
+ expect(() => check(feed)).toThrow(/value NAVI doesn't use/)
+ const pyth = await tampered(NAVI_WITHDRAW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'update_single_price_v3'), 4), OTHER_OBJECT)
+ })
+ expect(() => check(pyth)).toThrow(/object NAVI doesn't use/)
+ })
+})
+
+describe('NAVI refuses a borrow or repay transaction that', () => {
+ it('another account would sign', () => {
+ expect(() => check(NAVI_BORROW, OTHER_ADDRESS)).toThrow(/different account/)
+ expect(() => check(NAVI_REPAY, OTHER_ADDRESS)).toThrow(/different account/)
+ })
+
+ it('takes a token that is not listed', async () => {
+ const tx = await tampered(NAVI_BORROW, (t) => {
+ callNamed(t, 'entry_borrow_v2').typeArguments = [UNLISTED]
+ })
+ expect(() => check(tx, NAVI_BORROW_SENDER)).toThrow(/token NAVI isn't confirmed for/)
+ })
+
+ it('borrows from another pool or storage', async () => {
+ const tx = await tampered(NAVI_BORROW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'entry_borrow_v2'), 3), OTHER_OBJECT)
+ })
+ expect(() => check(tx, NAVI_BORROW_SENDER)).toThrow(/object NAVI doesn't use/)
+ })
+
+ it('repays through a contract call that pays for somebody else', async () => {
+ const tx = await tampered(NAVI_REPAY, (t) => {
+ callNamed(t, 'entry_repay').function = 'entry_repay_on_behalf_of_user'
+ })
+ expect(() => check(tx, NAVI_REPAY_SENDER)).toThrow(/haven't confirmed for NAVI/)
+ })
+
+ it('names another asset on a repay', async () => {
+ const tx = await tampered(NAVI_REPAY, (t) => {
+ const input = inputOf(t, callNamed(t, 'entry_repay'), 4)
+ if (input.Pure === undefined) throw new Error('not a literal')
+ input.Pure.bytes = 'AQ=='
+ })
+ expect(() => check(tx, NAVI_REPAY_SENDER)).toThrow(/value NAVI doesn't use/)
+ })
+
+ it('sends what is left over to someone else', async () => {
+ const base = Transaction.from(NAVI_REPAY)
+ base.transferObjects([base.gas], OTHER_ADDRESS)
+ const tx = toBase64(await base.build())
+ expect(() => check(tx, NAVI_REPAY_SENDER)).toThrow(/another address/)
+ })
+})
+
+describe('the transactions it builds', () => {
+ const usdc = { assetId: NAVI_USDC_ASSET_ID }
+
+ it('borrow exactly what was asked, with no recipient to change', () => {
+ const tx = naviBorrowTransaction(ME, 'USDC', BigInt(1_500_000), [NAVI_USDC_ASSET_ID])
+ expect(amountOf(tx, 'entry_borrow_v2', 5)).toBe(BigInt(1_500_000))
+ expect(tx.getData().commands.some((c) => c.$kind === 'TransferObjects')).toBe(false)
+ })
+
+ it('price every asset the position holds before a borrow', () => {
+ const tx = naviBorrowTransaction(ME, 'USDC', BigInt(1), [NAVI_USDC_ASSET_ID, SUI_ASSET])
+ const updates = tx
+ .getData()
+ .commands.filter(
+ (c) => c.$kind === 'MoveCall' && c.MoveCall.function === 'update_single_price_v3'
+ )
+ expect(updates).toHaveLength(2)
+ })
+
+ it('repay with no price update, since no check of the position runs', () => {
+ const tx = naviRepayTransaction(ME, 'USDC', BigInt(2_000_000))
+ const names = tx
+ .getData()
+ .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : []))
+ expect(names).toContain('entry_repay')
+ expect(names).not.toContain('update_single_price_v3')
+ expect(amountOf(tx, 'entry_repay', 6)).toBe(BigInt(2_000_000))
+ })
+
+ it('withdraw a number above the position on a whole withdraw, which the contract caps', () => {
+ expect(wholeWithdrawAmount(USDC_10) > USDC_10).toBe(true)
+ const tx = naviWithdrawTransaction(ME, 'USDC', wholeWithdrawAmount(USDC_10), [usdc.assetId])
+ expect(amountOf(tx, 'entry_withdraw', 5)).toBe(wholeWithdrawAmount(USDC_10))
+ })
+
+ it('withdraw SUI through the call that can unstake it', () => {
+ const tx = naviWithdrawTransaction(ME, 'SUI', BigInt(1), [SUI_ASSET])
+ const names = tx
+ .getData()
+ .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : []))
+ expect(names).toContain('entry_withdraw_v2')
+ expect(names).not.toContain('entry_withdraw')
+ })
+})
+
+describe('prepareNaviWithdraw refuses before anything is built', () => {
+ it('a token NAVI does not hold here', async () => {
+ await expect(
+ prepareNaviWithdraw(action('withdraw', '1', 'sSUI'), ME, reader({}, holdsUsdc))
+ ).rejects.toThrow(/NAVI doesn't take sSUI/)
+ })
+
+ it('a malformed account', async () => {
+ await expect(
+ prepareNaviWithdraw(action('withdraw'), 'nope', reader({}, holdsUsdc))
+ ).rejects.toThrow(/not a Sui address/)
+ })
+
+ it('more decimals than USDC has', async () => {
+ await expect(
+ prepareNaviWithdraw(action('withdraw', '0.0000001'), ME, reader({}, holdsUsdc))
+ ).rejects.toThrow(/decimal places/)
+ })
+
+ it('a wallet with nothing in NAVI', async () => {
+ await expect(prepareNaviWithdraw(action('withdraw'), ME, reader())).rejects.toThrow(
+ /no USDC in NAVI/
+ )
+ })
+
+ it('more than the wallet has there, naming what it has', async () => {
+ await expect(
+ prepareNaviWithdraw(action('withdraw', '11'), ME, reader({}, holdsUsdc))
+ ).rejects.toThrow(/You have about 10 USDC in NAVI, which is less than 11/)
+ })
+
+ it('collateral a loan depends on, in plain words', async () => {
+ const borrowed = account([[NAVI_USDC_ASSET_ID, USDC_10]], [[SUI_ASSET, BigInt(1)]])
+ const venue = reader(
+ { standing: async () => ({ liquidationUsd: 8.5, limitUsd: 8, owedUsd: 7 }) },
+ borrowed
+ )
+ await expect(prepareNaviWithdraw(action('withdraw', '10'), ME, venue)).rejects.toThrow(
+ /Your loan on NAVI depends on this USDC/
+ )
+ })
+
+ it('a position holding an asset it has no price feed for, naming where to go', async () => {
+ const odd = account([
+ [NAVI_USDC_ASSET_ID, USDC_10],
+ [UNKNOWN_ASSET, BigInt(1)],
+ ])
+ await expect(prepareNaviWithdraw(action('withdraw'), ME, reader({}, odd))).rejects.toThrow(
+ /app\.naviprotocol\.io/
+ )
+ })
+
+ it('stops, rather than guess, when the position cannot be read', async () => {
+ const down = reader({
+ account: async () => {
+ throw new Error('rpc down')
+ },
+ })
+ await expect(prepareNaviWithdraw(action('withdraw'), ME, down)).rejects.toThrow('rpc down')
+ })
+})
+
+describe('prepareNaviBorrow refuses before anything is built', () => {
+ it('a token NAVI does not lend here', async () => {
+ await expect(prepareNaviBorrow(action('borrow', '1', 'sSUI'), ME, reader())).rejects.toThrow(
+ /NAVI doesn't take sSUI/
+ )
+ })
+
+ it('a wallet with no collateral', async () => {
+ const none = reader({ standing: async () => ({ liquidationUsd: 0, limitUsd: 0, owedUsd: 0 }) })
+ await expect(prepareNaviBorrow(action('borrow'), ME, none)).rejects.toThrow(
+ /no collateral on NAVI/
+ )
+ })
+
+ it('more than the collateral can back, counting the fee', async () => {
+ const venue = reader(
+ {
+ borrowFeePercent: async () => 1,
+ standing: async () => ({ liquidationUsd: 1000, limitUsd: 10, owedUsd: 0 }),
+ },
+ holdsUsdc
+ )
+ await expect(prepareNaviBorrow(action('borrow', '10'), ME, venue)).rejects.toThrow(
+ /can back about 9\.9\d* more USDC/
+ )
+ })
+
+ it('a loan that would leave little room before liquidation', async () => {
+ const venue = reader(
+ { standing: async () => ({ liquidationUsd: 14, limitUsd: 12, owedUsd: 0 }) },
+ holdsUsdc
+ )
+ await expect(prepareNaviBorrow(action('borrow', '10'), ME, venue)).rejects.toThrow(
+ /health factor at 1\.40.*stops at 1\.5/
+ )
+ })
+})
+
+describe('prepareNaviRepay refuses before anything is built', () => {
+ it('a wallet that owes none of it', async () => {
+ await expect(prepareNaviRepay(action('repay'), ME, reader({}, holdsUsdc))).rejects.toThrow(
+ /don't owe any USDC on NAVI/
+ )
+ })
+
+ it('a whole repay the wallet cannot cover', async () => {
+ const owes = account([], [[NAVI_USDC_ASSET_ID, USDC_10]])
+ await expect(
+ prepareNaviRepay(
+ { ...action('repay', '10'), wholePosition: true },
+ ME,
+ reader({ walletBalance: async () => BigInt(1_000_000) }, owes)
+ )
+ ).rejects.toThrow(/You hold 1 USDC, which is less than the 10 you owe/)
+ })
+})
+
+describe('positions', () => {
+ it('reports what is supplied and owed in the listed tokens, in display units', async () => {
+ const held = account(
+ [
+ [NAVI_USDC_ASSET_ID, BigInt(12_500_000)],
+ [UNKNOWN_ASSET, BigInt(1)],
+ ],
+ [[SUI_ASSET, BigInt(2_000_000_000)]]
+ )
+ expect(await readNaviPositions(ME, reader({}, held))).toEqual([
+ { slot: 'borrowed', symbol: 'SUI', amount: '2' },
+ { slot: 'supplied', symbol: 'USDC', amount: '12.5' },
+ ])
+ })
+
+ it('is empty for a wallet with nothing there', async () => {
+ expect(await readNaviPositions(ME, reader())).toEqual([])
+ })
+
+ it('throws when it cannot read, and never reads as empty', async () => {
+ const down = reader({
+ account: async () => {
+ throw new Error('rpc down')
+ },
+ })
+ await expect(readNaviPositions(ME, down)).rejects.toThrow('rpc down')
+ await expect(createNaviVenue(down).positions?.(ME)).rejects.toThrow('rpc down')
+ })
+})
+
+describe('the venue', () => {
+ it('supplies, withdraws, borrows and repays', () => {
+ expect(naviVenue.kinds).toEqual(['supply', 'withdraw', 'borrow', 'repay'])
+ })
+
+ it('still supplies USDC only', async () => {
+ await expect(
+ createNaviVenue(reader()).prepare(action('supply', '1', 'SUI'), ME)
+ ).rejects.toThrow(/takes USDC here, not SUI/)
+ })
+
+ it('quotes no rate on a loan, where a saving rate would mislead', async () => {
+ expect(await createNaviVenue(reader()).quote?.(action('borrow'), ME)).toEqual({})
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-exits.live.test.ts b/apps/dapp/lib/__tests__/near-exits.live.test.ts
new file mode 100644
index 00000000..66fe4ef5
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-exits.live.test.ts
@@ -0,0 +1,236 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { earnVenue } from '../chains/near/earn'
+import { clearTokenCache, requestQuote } from '../chains/near/oneclick'
+import { viewCall } from '../chains/near/rpc'
+import { INTENTS_CONTRACT } from '../chains/near/safety'
+import { metapoolVenue, rnearVenue } from '../chains/near/stake'
+import { METAPOOL_CONTRACT, RNEAR_CONTRACT, USDC_NEAR } from '../chains/near/tokens'
+
+/**
+ * The real contracts and 1Click, read-only. Nothing signs or sends, and what gets built is never
+ * given to a wallet. NEAR has no way to simulate a change call, so a payload's method and argument
+ * names are held against the contract's own exports and the ABI it reports.
+ *
+ * `ONECLICK_LIVE_ADDRESS=1` also runs the one test that asks for a real deposit address. It moves
+ * nothing, but it leaves a record at 1Click, so it doesn't run by default.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const RPC = process.env['NEAR_RPC_URL'] ?? 'https://free.rpc.fastnear.com'
+const VAULT = 'nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near'
+const USDC = `nep141:${USDC_NEAR}`
+
+async function code(contract: string): Promise {
+ const response = await fetch(RPC, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 'test',
+ method: 'query',
+ params: { request_type: 'view_code', finality: 'final', account_id: contract },
+ }),
+ signal: AbortSignal.timeout(30_000),
+ })
+ const body = (await response.json()) as { result: { code_base64: string } }
+ const bytes = Buffer.from(body.result.code_base64, 'base64')
+ let at = 8
+ const leb = (): number => {
+ let value = 0
+ let shift = 0
+ let byte: number
+ do {
+ byte = bytes[at] as number
+ at += 1
+ value |= (byte & 0x7f) << shift
+ shift += 7
+ } while (byte & 0x80)
+ return value >>> 0
+ }
+ while (at < bytes.length) {
+ const section = bytes[at] as number
+ at += 1
+ const end = leb() + at
+ if (section === 7) {
+ const names: string[] = []
+ const count = leb()
+ for (let i = 0; i < count; i += 1) {
+ const size = leb()
+ names.push(bytes.subarray(at, at + size).toString())
+ at += size + 1
+ leb()
+ }
+ return names
+ }
+ at = end
+ }
+ return []
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ clearTokenCache()
+})
+
+describe.skipIf(SKIP)('the exit methods on the deployed contracts', () => {
+ it.each([
+ [
+ METAPOOL_CONTRACT,
+ ['unstake', 'unstake_all', 'withdraw_unstaked', 'get_account_info', 'get_contract_state'],
+ ],
+ [
+ RNEAR_CONTRACT,
+ ['unstake', 'unstake_all', 'withdraw_all', 'get_account_details', 'ft_balance_of'],
+ ],
+ [INTENTS_CONTRACT, ['mt_transfer', 'ft_withdraw', 'mt_balance_of']],
+ ])(
+ '%s exports what this app calls',
+ async (contract, methods) => {
+ const exported = await code(contract)
+ for (const method of methods) expect(exported, method).toContain(method)
+ },
+ 60_000
+ )
+
+ it('reports the Meta Pool delay as a number of epochs', async () => {
+ const epochs = await viewCall(METAPOOL_CONTRACT, 'compute_current_unstaking_delay', {
+ amount: '1000000000000000000000000',
+ })
+ expect([4, 8]).toContain(epochs)
+ }, 30_000)
+
+ it('leaves a new account able to use the Intents exit without a signed message', async () => {
+ await expect(
+ viewCall(INTENTS_CONTRACT, 'is_auth_by_predecessor_id_enabled', {
+ account_id: 'root.near',
+ })
+ ).resolves.toBe(true)
+ }, 30_000)
+})
+
+describe.skipIf(SKIP)('a real holder', () => {
+ it('reads Meta Pool, builds both unstakes and passes its own check', async () => {
+ const positions = await metapoolVenue.positions?.('root.near')
+ const staked = positions?.find((p) => p.slot === 'staked')
+ expect(staked?.symbol).toBe('stNEAR')
+ expect(Number(staked?.amount)).toBeGreaterThan(0)
+
+ const whole = await metapoolVenue.prepare(
+ {
+ kind: 'unstake',
+ amount: staked?.amount ?? '0',
+ tokenIn: 'stNEAR',
+ wholePosition: true,
+ },
+ 'root.near'
+ )
+ expect(() => metapoolVenue.assertSafe(whole.signing, 'root.near')).not.toThrow()
+ const part = await metapoolVenue.prepare(
+ { kind: 'unstake', amount: '0.1', tokenIn: 'stNEAR' },
+ 'root.near'
+ )
+ expect(() => metapoolVenue.assertSafe(part.signing, 'root.near')).not.toThrow()
+ expect(Number(part.receive?.amount)).toBeGreaterThan(0.1)
+ expect(part.notes.join(' ')).toMatch(/(4|8) epochs/)
+ }, 60_000)
+
+ it('reads rNEAR from the first accounts that hold some, and builds their exit', async () => {
+ const accounts = await viewCall<
+ { account_id: string; staked_balance: string; unstaked_balance: string }[]
+ >(RNEAR_CONTRACT, 'get_accounts', { from_index: 0, limit: 40 })
+ const holder = accounts.find((a) => BigInt(a.staked_balance) > BigInt(0))
+ if (holder === undefined) throw new Error('no holder in the first accounts')
+
+ const positions = await rnearVenue.positions?.(holder.account_id)
+ expect(positions?.find((p) => p.slot === 'staked')?.symbol).toBe('rNEAR')
+ const whole = await rnearVenue.prepare(
+ { kind: 'unstake', amount: '1', tokenIn: 'rNEAR', wholePosition: true },
+ holder.account_id
+ )
+ expect(() => rnearVenue.assertSafe(whole.signing, holder.account_id)).not.toThrow()
+ }, 60_000)
+
+ it('reads a real unstake on its way at each venue, and builds or refuses its claim', async () => {
+ const venues = [
+ { venue: metapoolVenue, contract: METAPOOL_CONTRACT },
+ { venue: rnearVenue, contract: RNEAR_CONTRACT },
+ ]
+ for (const { venue, contract } of venues) {
+ const pending: { account_id: string; unstaked_balance: string }[] = []
+ for (let from = 0; from < 2000 && pending.length === 0; from += 100) {
+ const page = await viewCall<{ account_id: string; unstaked_balance: string }[]>(
+ contract,
+ 'get_accounts',
+ { from_index: from, limit: 100 }
+ )
+ pending.push(
+ ...page.filter((a) => BigInt(a.unstaked_balance) > BigInt('1000000000000000000000'))
+ )
+ }
+ const account = pending[0]?.account_id
+ if (account === undefined) throw new Error(`no pending unstake found on ${contract}`)
+
+ const positions = await venue.positions?.(account)
+ const waiting = positions?.find((p) => p.slot === 'claimable' || p.slot === 'cooling')
+ expect(waiting?.symbol, contract).toBe('NEAR')
+ const claim = { kind: 'claim', amount: 'all', tokenIn: 'NEAR' } as const
+ if (waiting?.slot === 'claimable') {
+ const prepared = await venue.prepare(claim, account)
+ expect(() => venue.assertSafe(prepared.signing, account)).not.toThrow()
+ } else {
+ await expect(venue.prepare(claim, account)).rejects.toThrow(/isn't ready yet/)
+ }
+ }
+ }, 120_000)
+
+ it('reads Earn positions from the chain for an account with none', async () => {
+ await expect(earnVenue.positions?.('root.near')).resolves.toEqual([])
+ }, 30_000)
+})
+
+describe.skipIf(SKIP)('selling a vault share through 1Click', () => {
+ it('quotes it from the balance to the balance, signed by 1Click', async () => {
+ const envelope = await requestQuote({
+ dry: true,
+ originAsset: VAULT,
+ destinationAsset: USDC,
+ amount: '90000000000000000000000000',
+ account: 'root.near',
+ recipientType: 'INTENTS',
+ deadline: new Date(Date.now() + 5 * 60_000).toISOString(),
+ slippageBps: 100,
+ fromIntents: true,
+ })
+ expect(envelope.quoteRequest).toMatchObject({
+ depositType: 'INTENTS',
+ refundType: 'INTENTS',
+ recipientType: 'INTENTS',
+ refundTo: 'root.near',
+ })
+ expect(BigInt(envelope.quote.amountOut)).toBeGreaterThan(BigInt(0))
+ const loss = 1 - Number(envelope.quote.amountOutUsd) / Number(envelope.quote.amountInUsd)
+ expect(loss).toBeLessThan(0.05)
+ }, 45_000)
+
+ it('builds a real sale for a pretend balance, whose address passes the check', async () => {
+ if (process.env['ONECLICK_LIVE_ADDRESS'] !== '1') return
+ const real = globalThis.fetch
+ vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => {
+ const body = typeof init?.body === 'string' ? init.body : ''
+ if (body.includes('"mt_balance_of"')) {
+ const shares = JSON.stringify('90000000000000000000000000')
+ return new Response(JSON.stringify({ result: { result: [...Buffer.from(shares)] } }), {
+ status: 200,
+ })
+ }
+ return real(input, init)
+ })
+ const prepared = await earnVenue.prepare(
+ { kind: 'withdraw', amount: '100', tokenIn: 'USDC', wholePosition: true },
+ 'root.near'
+ )
+ expect(() => earnVenue.assertSafe(prepared.signing, 'root.near')).not.toThrow()
+ expect(prepared.context?.['depositAddress']).toMatch(/^[0-9a-f]{64}$/)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/near-exits.test.ts b/apps/dapp/lib/__tests__/near-exits.test.ts
new file mode 100644
index 00000000..f9d761b7
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-exits.test.ts
@@ -0,0 +1,740 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { earnVenue } from '../chains/near/earn'
+import { clearTokenCache } from '../chains/near/oneclick'
+import { assertMetapoolSafe, assertRnearSafe, INTENTS_CONTRACT } from '../chains/near/safety'
+import { confirmStake, metapoolVenue, rnearVenue } from '../chains/near/stake'
+import { METAPOOL_CONTRACT, RNEAR_CONTRACT, USDC_NEAR } from '../chains/near/tokens'
+import { prepareChainAction } from '../chains/prepare'
+import {
+ ChainActionRefused,
+ type ChainActionRequest,
+ type ChainModule,
+ type NearTransaction,
+ type SigningPayload,
+} from '../chains/types'
+
+/** Every path here ends in a NEAR wallet that sends what it signs. Nothing signs or sends in tests. */
+
+const ME = 'alice.near'
+const HASH = 'G6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScA'
+const DEPOSIT = 'dc2c3034e3ebefa1af9a2c419f896d7b761a056244099eefcd09aeb7efc1b86d'
+const VAULT = 'nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near'
+const USDC_ASSET = `nep141:${USDC_NEAR}`
+const ONE = '1000000000000000000000000'
+const TWO = '2000000000000000000000000'
+
+interface Fake {
+ views: Record
+ balances: Record
+ contractBalance: string
+ down: boolean
+ quotesDown: boolean
+ quotes: Record[]
+ tx: Record | undefined
+}
+let fake: Fake
+
+function quoteFor(request: Record): unknown {
+ const amount = String(request['amount'])
+ // A share is worth 1e-12 USDC in base units here: 2e18 shares come to 2 USDC.
+ const out = ((BigInt(amount) * BigInt(1000000)) / BigInt('1000000000000000000')).toString()
+ return {
+ quote: {
+ amountIn: amount,
+ amountInFormatted: amount,
+ amountInUsd: '2.00',
+ minAmountIn: amount,
+ amountOut: out,
+ amountOutFormatted: out,
+ amountOutUsd: '1.99',
+ minAmountOut: ((BigInt(out) * BigInt(99)) / BigInt(100)).toString(),
+ ...(request['dry'] === false ? { depositAddress: DEPOSIT } : {}),
+ },
+ quoteRequest: request,
+ signature: 'ed25519:test',
+ timestamp: '2026-11-05T00:00:00.000Z',
+ }
+}
+
+vi.mock('../chains/near/quote-signature', () => ({ verifyQuoteSignature: () => true }))
+
+function install(): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: string | URL, init?: RequestInit) => {
+ const url = String(input)
+ const json = (data: unknown, status = 200): Response =>
+ new Response(JSON.stringify(data), { status })
+ if (url.endsWith('/v0/tokens')) {
+ const priced = (assetId: string, decimals: number, price: number) => ({
+ assetId,
+ decimals,
+ symbol: assetId,
+ price,
+ priceUpdatedAt: new Date().toISOString(),
+ })
+ return json([priced(VAULT, 18, 1), priced(USDC_ASSET, 6, 1)])
+ }
+ if (url.endsWith('/v0/quote')) {
+ if (fake.quotesDown) return json({ message: 'Quoting for this pair is not available' }, 400)
+ const request = JSON.parse(init?.body as string) as Record
+ fake.quotes.push(request)
+ return json(quoteFor(request))
+ }
+ if (url.includes('/v0/status') || url.includes('/v0/deposit/submit')) return json({})
+ if (fake.down) throw new Error('down')
+ const body = JSON.parse(init?.body as string) as {
+ method: string
+ params: {
+ request_type?: string
+ account_id?: string
+ method_name?: string
+ args_base64?: string
+ }
+ }
+ if (body.method === 'tx') {
+ return json(
+ fake.tx === undefined
+ ? { error: { cause: { name: 'UNKNOWN_TRANSACTION' } } }
+ : { result: fake.tx }
+ )
+ }
+ if (body.params.request_type === 'view_account') {
+ return json({ result: { amount: fake.contractBalance } })
+ }
+ const encode = (value: unknown) => ({
+ result: { result: [...Buffer.from(JSON.stringify(value))] },
+ })
+ if (body.params.method_name === 'mt_balance_of') {
+ const args = JSON.parse(
+ Buffer.from(body.params.args_base64 ?? '', 'base64').toString()
+ ) as {
+ token_id: string
+ }
+ return json(encode(fake.balances[args.token_id] ?? '0'))
+ }
+ const key = `${body.params.account_id}.${body.params.method_name}`
+ if (!(key in fake.views)) return json({ result: { error: `no ${key}` } })
+ return json(encode(fake.views[key]))
+ })
+ )
+}
+
+const metaAccount = (over: Record = {}) => ({
+ st_near: TWO,
+ unstaked: '0',
+ can_withdraw: true,
+ unstake_full_epochs_wait_left: 0,
+ ...over,
+})
+const rnearAccount = (over: Record = {}) => ({
+ unstaked_balance: '0',
+ unstaked_available_epoch_height: 0,
+ can_withdraw: true,
+ ...over,
+})
+
+beforeEach(() => {
+ clearTokenCache()
+ fake = {
+ views: {
+ [`${METAPOOL_CONTRACT}.get_account_info`]: metaAccount(),
+ [`${METAPOOL_CONTRACT}.get_st_near_price`]: '1500000000000000000000000',
+ [`${METAPOOL_CONTRACT}.compute_current_unstaking_delay`]: 4,
+ [`${METAPOOL_CONTRACT}.get_contract_state`]: {
+ env_epoch_height: '100',
+ retrieved_for_unstake_claims: TWO,
+ },
+ [`${RNEAR_CONTRACT}.get_account_details`]: rnearAccount(),
+ [`${RNEAR_CONTRACT}.ft_balance_of`]: TWO,
+ [`${RNEAR_CONTRACT}.ft_price`]: '1100000000000000000000000',
+ [`${RNEAR_CONTRACT}.is_paused`]: false,
+ [`${USDC_NEAR}.storage_balance_of`]: { total: '1', available: '0' },
+ },
+ balances: {},
+ contractBalance: `5${ONE}`,
+ down: false,
+ quotesDown: false,
+ quotes: [],
+ tx: undefined,
+ }
+ install()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+})
+
+const ask = (over: Partial): ChainActionRequest => ({
+ kind: 'unstake',
+ amount: '1',
+ tokenIn: 'stNEAR',
+ ...over,
+})
+
+function txs(prepared: { signing: SigningPayload }): NearTransaction[] {
+ return (prepared.signing as { transactions: NearTransaction[] }).transactions
+}
+
+function call(prepared: { signing: SigningPayload }) {
+ const [tx] = txs(prepared)
+ return { receiverId: tx?.receiverId, params: tx?.actions[0]?.params }
+}
+
+describe('unstaking from Meta Pool', () => {
+ it('turns a part of your stNEAR into its NEAR value, and passes its own check', async () => {
+ const prepared = await metapoolVenue.prepare(ask({ amount: '1' }), ME)
+ expect(call(prepared)).toEqual({
+ receiverId: METAPOOL_CONTRACT,
+ params: {
+ methodName: 'unstake',
+ args: { amount: '1500000000000000000000000' },
+ gas: '100000000000000',
+ deposit: '0',
+ },
+ })
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'stNEAR' })
+ expect(prepared.receive).toEqual({ amount: '1.5', symbol: 'NEAR', approximate: true })
+ expect(prepared.notes.join(' ')).toMatch(/4 epochs.*about 29 hours.*claim my NEAR/)
+ expect(() => metapoolVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('empties the position exactly when it is the whole position', async () => {
+ const prepared = await metapoolVenue.prepare(ask({ amount: '2', wholePosition: true }), ME)
+ expect(call(prepared).params).toMatchObject({ methodName: 'unstake_all', args: {} })
+ expect(() => metapoolVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('refuses more than you hold, none at all, and a token it did not issue', async () => {
+ await expect(metapoolVenue.prepare(ask({ amount: '3' }), ME)).rejects.toThrow(
+ /You hold 2 stNEAR/
+ )
+ await expect(metapoolVenue.prepare(ask({ tokenIn: 'rNEAR' }), ME)).rejects.toThrow(
+ /say it in stNEAR/
+ )
+ fake.views[`${METAPOOL_CONTRACT}.get_account_info`] = metaAccount({ st_near: '0' })
+ await expect(metapoolVenue.prepare(ask({}), ME)).rejects.toThrow(/hold no stNEAR/)
+ })
+
+ it('refuses when NEAR cannot be read', async () => {
+ fake.down = true
+ await expect(metapoolVenue.prepare(ask({}), ME)).rejects.toThrow(/could not be read/)
+ })
+})
+
+describe('unstaking rNEAR', () => {
+ it('builds the same two forms at its own price and gas', async () => {
+ const part = await rnearVenue.prepare(ask({ tokenIn: 'rNEAR' }), ME)
+ expect(call(part)).toEqual({
+ receiverId: RNEAR_CONTRACT,
+ params: {
+ methodName: 'unstake',
+ args: { amount: '1100000000000000000000000' },
+ gas: '200000000000000',
+ deposit: '0',
+ },
+ })
+ expect(() => rnearVenue.assertSafe(part.signing, ME)).not.toThrow()
+ const whole = await rnearVenue.prepare(ask({ tokenIn: 'rNEAR', wholePosition: true }), ME)
+ expect(call(whole).params).toMatchObject({ methodName: 'unstake_all', args: {} })
+ expect(() => rnearVenue.assertSafe(whole.signing, ME)).not.toThrow()
+ expect(part.notes.join(' ')).toMatch(/source link/)
+ })
+
+ it('refuses while the contract is paused', async () => {
+ fake.views[`${RNEAR_CONTRACT}.is_paused`] = true
+ await expect(rnearVenue.prepare(ask({ tokenIn: 'rNEAR' }), ME)).rejects.toThrow(/paused/)
+ })
+})
+
+describe('what each stake venue holds', () => {
+ it('shows the token in the wallet as staked', async () => {
+ await expect(metapoolVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'staked', symbol: 'stNEAR', amount: '2' },
+ ])
+ await expect(rnearVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'staked', symbol: 'rNEAR', amount: '2' },
+ ])
+ })
+
+ it('shows a pending unstake as cooling, with the hours the chain implies', async () => {
+ fake.views[`${METAPOOL_CONTRACT}.get_account_info`] = metaAccount({
+ st_near: '0',
+ unstaked: ONE,
+ can_withdraw: false,
+ unstake_full_epochs_wait_left: 2,
+ })
+ await expect(metapoolVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'cooling', symbol: 'NEAR', amount: '1', readyInHours: 15 },
+ ])
+ fake.views[`${RNEAR_CONTRACT}.ft_balance_of`] = '0'
+ fake.views[`${RNEAR_CONTRACT}.get_account_details`] = rnearAccount({
+ unstaked_balance: ONE,
+ unstaked_available_epoch_height: 103,
+ can_withdraw: false,
+ })
+ await expect(rnearVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'cooling', symbol: 'NEAR', amount: '1', readyInHours: 22 },
+ ])
+ })
+
+ it('shows a finished wait as claimable, unless the contract has not got the money yet', async () => {
+ fake.views[`${METAPOOL_CONTRACT}.get_account_info`] = metaAccount({
+ st_near: '0',
+ unstaked: ONE,
+ })
+ await expect(metapoolVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'claimable', symbol: 'NEAR', amount: '1' },
+ ])
+ fake.views[`${METAPOOL_CONTRACT}.get_contract_state`] = { retrieved_for_unstake_claims: '0' }
+ await expect(metapoolVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'cooling', symbol: 'NEAR', amount: '1', readyInHours: undefined },
+ ])
+
+ fake.views[`${RNEAR_CONTRACT}.ft_balance_of`] = '0'
+ fake.views[`${RNEAR_CONTRACT}.get_account_details`] = rnearAccount({ unstaked_balance: ONE })
+ await expect(rnearVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'claimable', symbol: 'NEAR', amount: '1' },
+ ])
+ fake.contractBalance = ONE
+ await expect(rnearVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'cooling', symbol: 'NEAR', amount: '1', readyInHours: undefined },
+ ])
+ })
+
+ it('ignores the dust a stake leaves in the unstaked balance', async () => {
+ fake.views[`${RNEAR_CONTRACT}.get_account_details`] = rnearAccount({
+ unstaked_balance: '9999999999999999999',
+ })
+ await expect(rnearVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'staked', symbol: 'rNEAR', amount: '2' },
+ ])
+ })
+
+ it('throws when it cannot read, and never reads as empty', async () => {
+ fake.down = true
+ await expect(metapoolVenue.positions?.(ME)).rejects.toThrow()
+ await expect(rnearVenue.positions?.(ME)).rejects.toThrow()
+ fake.down = false
+ delete fake.views[`${METAPOOL_CONTRACT}.get_account_info`]
+ await expect(metapoolVenue.positions?.(ME)).rejects.toThrow()
+ })
+})
+
+describe('claiming what an unstake left waiting', () => {
+ const claim = (tokenIn = 'NEAR'): ChainActionRequest => ({
+ kind: 'claim',
+ amount: 'all',
+ tokenIn,
+ })
+
+ it('collects it with the one method that pays the caller', async () => {
+ fake.views[`${METAPOOL_CONTRACT}.get_account_info`] = metaAccount({ unstaked: ONE })
+ const meta = await metapoolVenue.prepare(claim(), ME)
+ expect(call(meta)).toEqual({
+ receiverId: METAPOOL_CONTRACT,
+ params: {
+ methodName: 'withdraw_unstaked',
+ args: {},
+ gas: '100000000000000',
+ deposit: '0',
+ },
+ })
+ expect(() => metapoolVenue.assertSafe(meta.signing, ME)).not.toThrow()
+
+ fake.views[`${RNEAR_CONTRACT}.get_account_details`] = rnearAccount({ unstaked_balance: ONE })
+ const rnear = await rnearVenue.prepare(claim(), ME)
+ expect(call(rnear).params).toMatchObject({ methodName: 'withdraw_all', args: {}, deposit: '0' })
+ expect(() => rnearVenue.assertSafe(rnear.signing, ME)).not.toThrow()
+ })
+
+ it('says how long is left', async () => {
+ fake.views[`${METAPOOL_CONTRACT}.get_account_info`] = metaAccount({
+ unstaked: ONE,
+ can_withdraw: false,
+ unstake_full_epochs_wait_left: 3,
+ })
+ await expect(metapoolVenue.prepare(claim(), ME)).rejects.toThrow(
+ /isn't ready yet.*about 22 hours.*Nothing was signed or sent/
+ )
+ })
+
+ it('says so when the wait is over but the money is not back', async () => {
+ fake.views[`${METAPOOL_CONTRACT}.get_account_info`] = metaAccount({ unstaked: ONE })
+ fake.views[`${METAPOOL_CONTRACT}.get_contract_state`] = { retrieved_for_unstake_claims: '0' }
+ await expect(metapoolVenue.prepare(claim(), ME)).rejects.toThrow(/validators/)
+ })
+
+ it('refuses when nothing is waiting, or for a token it does not pay', async () => {
+ await expect(metapoolVenue.prepare(claim(), ME)).rejects.toThrow(/nothing unstaked waiting/)
+ await expect(metapoolVenue.prepare(claim('USDC'), ME)).rejects.toThrow(/pays out NEAR/)
+ })
+})
+
+describe('the allowlist for a stake exit', () => {
+ const exit = (
+ method: string,
+ args: Record = {},
+ deposit = '0',
+ gas = '100000000000000'
+ ): NearTransaction['actions'][number] => ({
+ type: 'FunctionCall',
+ params: { methodName: method, args, gas, deposit },
+ })
+ const payload = (
+ actions: NearTransaction['actions'],
+ receiverId = METAPOOL_CONTRACT
+ ): SigningPayload => ({ family: 'near', transactions: [{ receiverId, actions }] })
+ const refused = (value: SigningPayload, check = assertMetapoolSafe) =>
+ expect(() => check(value, ME)).toThrow(ChainActionRefused)
+
+ it('takes each exit and nothing wider', () => {
+ expect(() => assertMetapoolSafe(payload([exit('unstake_all')]), ME)).not.toThrow()
+ expect(() => assertMetapoolSafe(payload([exit('withdraw_unstaked')]), ME)).not.toThrow()
+ expect(() => assertMetapoolSafe(payload([exit('unstake', { amount: '5' })]), ME)).not.toThrow()
+ for (const method of ['withdraw_all', 'withdraw', 'liquid_unstake', 'nslp_remove_liquidity']) {
+ refused(payload([exit(method, method === 'withdraw' ? { amount: '5' } : {})]))
+ }
+ refused(payload([exit('withdraw_unstaked', {}, '0')], RNEAR_CONTRACT), assertRnearSafe)
+ expect(() =>
+ assertRnearSafe(
+ payload([exit('withdraw_all', {}, '0', '200000000000000')], RNEAR_CONTRACT),
+ ME
+ )
+ ).not.toThrow()
+ })
+
+ it('refuses a tampered exit', () => {
+ refused(payload([exit('unstake_all')], 'evil.near'))
+ refused(payload([exit('unstake_all', {}, '1')]))
+ refused(payload([exit('unstake_all', { account_id: 'evil.near' })]))
+ refused(payload([exit('unstake_all', {}, '0', '100000000000001')]))
+ refused(payload([exit('unstake', { amount: '0' })]))
+ refused(payload([exit('unstake', { amount: '-1' })]))
+ refused(payload([exit('unstake', { amount: '5', beneficiary: 'evil.near' })]))
+ refused(payload([exit('unstake', {})]))
+ refused(payload([exit('unstake_all'), exit('unstake_all')]))
+ refused(payload([exit('deposit_and_stake', {}, ONE), exit('unstake_all')]))
+ })
+})
+
+describe('taking money out of 1Click Earn', () => {
+ const SHARES = '2000000000000000000'
+ const withdraw = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'withdraw',
+ amount: '2',
+ tokenIn: 'USDC',
+ ...over,
+ })
+
+ it('reads shares and waiting USDC from the NEAR Intents contract', async () => {
+ fake.balances[VAULT] = SHARES
+ fake.balances[USDC_ASSET] = '500000'
+ await expect(earnVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '2' },
+ { slot: 'claimable', symbol: 'USDC', amount: '0.5' },
+ ])
+ })
+
+ it('reads as empty only when both balances are read as zero', async () => {
+ await expect(earnVenue.positions?.(ME)).resolves.toEqual([])
+ expect(fake.quotes).toEqual([])
+ })
+
+ it('throws when the chain or the price cannot be read', async () => {
+ fake.down = true
+ await expect(earnVenue.positions?.(ME)).rejects.toThrow()
+ fake.down = false
+ fake.balances[VAULT] = SHARES
+ fake.quotesDown = true
+ await expect(earnVenue.positions?.(ME)).rejects.toThrow(/isn't offering this route/)
+ })
+
+ it('sells every share when it is the whole position, from and back to the balance', async () => {
+ fake.balances[VAULT] = SHARES
+ const prepared = await earnVenue.prepare(withdraw({ wholePosition: true }), ME)
+ expect(call(prepared)).toEqual({
+ receiverId: INTENTS_CONTRACT,
+ params: {
+ methodName: 'mt_transfer',
+ args: { receiver_id: DEPOSIT, token_id: VAULT, amount: SHARES },
+ gas: '100000000000000',
+ deposit: '1',
+ },
+ })
+ expect(() => earnVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(prepared.kind).toBe('withdraw')
+ expect(prepared.context).toMatchObject({ depositAddress: DEPOSIT })
+ expect(prepared.notes.join(' ')).toMatch(/first of two steps.*claim my USDC/)
+ for (const request of fake.quotes) {
+ expect(request).toMatchObject({
+ depositType: 'INTENTS',
+ refundType: 'INTENTS',
+ recipientType: 'INTENTS',
+ refundTo: ME,
+ recipient: ME,
+ originAsset: VAULT,
+ destinationAsset: USDC_ASSET,
+ amount: SHARES,
+ })
+ }
+ expect(fake.quotes.at(-1)).toMatchObject({ dry: false })
+ })
+
+ it('sells only the share of the position that was asked for', async () => {
+ fake.balances[VAULT] = SHARES
+ const prepared = await earnVenue.prepare(withdraw({ amount: '0.5' }), ME)
+ expect(call(prepared).params?.args).toMatchObject({ amount: '500000000000000000' })
+ })
+
+ it('refuses more than the position is worth, nothing held, and other tokens', async () => {
+ fake.balances[VAULT] = SHARES
+ await expect(earnVenue.prepare(withdraw({ amount: '3' }), ME)).rejects.toThrow(
+ /worth about 2 USDC/
+ )
+ await expect(earnVenue.prepare(withdraw({ tokenIn: 'USDT' }), ME)).rejects.toThrow(
+ /pays out in USDC/
+ )
+ delete fake.balances[VAULT]
+ await expect(earnVenue.prepare(withdraw(), ME)).rejects.toThrow(/nothing in 1Click Earn/)
+ await expect(earnVenue.prepare(withdraw(), 'not a near account!')).rejects.toThrow(
+ /not a NEAR account/
+ )
+ })
+
+ it('does not build a sale 1Click will not quote', async () => {
+ fake.balances[VAULT] = SHARES
+ fake.quotesDown = true
+ await expect(earnVenue.prepare(withdraw({ wholePosition: true }), ME)).rejects.toThrow(
+ /isn't offering this route.*Nothing was signed or sent/
+ )
+ })
+})
+
+describe('bringing USDC out of the NEAR Intents balance', () => {
+ const claim: ChainActionRequest = { kind: 'claim', amount: 'all', tokenIn: 'USDC' }
+
+ it('needs only the contract, and pays only the signer', async () => {
+ fake.balances[USDC_ASSET] = '2500000'
+ fake.quotesDown = true
+ const prepared = await earnVenue.prepare(claim, ME)
+ expect(call(prepared)).toEqual({
+ receiverId: INTENTS_CONTRACT,
+ params: {
+ methodName: 'ft_withdraw',
+ args: { token: USDC_NEAR, receiver_id: ME, amount: '2500000' },
+ gas: '100000000000000',
+ deposit: '1',
+ },
+ })
+ expect(fake.quotes).toEqual([])
+ expect(() => earnVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('registers someone who entered with USDT and has never held USDC', async () => {
+ fake.balances[USDC_ASSET] = '2500000'
+ fake.views[`${USDC_NEAR}.storage_balance_of`] = null
+ const prepared = await earnVenue.prepare(claim, ME)
+ const [first, second] = txs(prepared)
+ expect(first?.receiverId).toBe(USDC_NEAR)
+ expect(first?.actions[0]?.params.methodName).toBe('storage_deposit')
+ expect(second?.receiverId).toBe(INTENTS_CONTRACT)
+ expect(() => earnVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('refuses when nothing is waiting', async () => {
+ await expect(earnVenue.prepare(claim, ME)).rejects.toThrow(/no USDC waiting/)
+ })
+
+ it('reaches here through the shared route, sized by the venue', async () => {
+ fake.balances[USDC_ASSET] = '2500000'
+ const nearOnly = {
+ chain: 'near',
+ tokens: [{ symbol: 'USDC', id: USDC_NEAR, decimals: 6 }],
+ venues: [earnVenue],
+ defaultVenue: {},
+ priceUsd: async () => 1,
+ balances: async () => ({ tokens: [], complete: true }),
+ } as unknown as ChainModule
+ const prepared = await prepareChainAction(nearOnly, claim, ME)
+ expect(prepared.kind).toBe('claim')
+ })
+})
+
+describe('the allowlist for an Earn exit', () => {
+ const sale = (over: Record = {}, deposit = '1', gas = '100000000000000') => ({
+ type: 'FunctionCall' as const,
+ params: {
+ methodName: 'mt_transfer',
+ args: { receiver_id: DEPOSIT, token_id: VAULT, amount: '5', ...over },
+ gas,
+ deposit,
+ },
+ })
+ const withdrawal = (over: Record = {}, deposit = '1') => ({
+ type: 'FunctionCall' as const,
+ params: {
+ methodName: 'ft_withdraw',
+ args: { token: USDC_NEAR, receiver_id: ME, amount: '5', ...over },
+ gas: '100000000000000',
+ deposit,
+ },
+ })
+ const on = (actions: NearTransaction['actions'], receiverId = INTENTS_CONTRACT) => ({
+ receiverId,
+ actions,
+ })
+ const near = (...transactions: NearTransaction[]): SigningPayload => ({
+ family: 'near',
+ transactions,
+ })
+ const refused = (payload: SigningPayload) =>
+ expect(() => earnVenue.assertSafe(payload, ME)).toThrow(ChainActionRefused)
+
+ it('takes what it builds', () => {
+ expect(() => earnVenue.assertSafe(near(on([sale()])), ME)).not.toThrow()
+ expect(() => earnVenue.assertSafe(near(on([withdrawal()])), ME)).not.toThrow()
+ })
+
+ it('refuses a tampered sale', () => {
+ refused(near(on([sale({ receiver_id: 'evil.near' })])))
+ refused(near(on([sale({ receiver_id: ME })])))
+ refused(near(on([sale({ token_id: USDC_ASSET })])))
+ refused(near(on([sale({ amount: '0' })])))
+ refused(near(on([sale({ memo: 'x' })])))
+ refused(near(on([sale({}, '2')])))
+ refused(near(on([sale({}, '1', '100000000000001')])))
+ refused(near(on([sale(), sale()])))
+ refused(near(on([sale()]), on([sale()])))
+ })
+
+ it('refuses a withdrawal that pays anyone else or calls anything else', () => {
+ refused(near(on([withdrawal({ receiver_id: 'evil.near' })])))
+ refused(near(on([withdrawal({ token: 'wrap.near' })])))
+ refused(near(on([withdrawal({ msg: 'call me' })])))
+ refused(near(on([withdrawal({ memo: 'WITHDRAW_TO:0xabc' })])))
+ refused(near(on([withdrawal({}, '0')])))
+ const swapped = withdrawal()
+ swapped.params.methodName = 'mt_withdraw'
+ refused(near(on([swapped])))
+ swapped.params.methodName = 'execute_intents'
+ refused(near(on([swapped])))
+ })
+
+ it('takes a registration first only if it is for the signer on USDC', () => {
+ const register = (accountId: string, receiverId = USDC_NEAR) =>
+ on(
+ [
+ {
+ type: 'FunctionCall',
+ params: {
+ methodName: 'storage_deposit',
+ args: { account_id: accountId, registration_only: true },
+ gas: '30000000000000',
+ deposit: '1250000000000000000000',
+ },
+ },
+ ],
+ receiverId
+ )
+ expect(() => earnVenue.assertSafe(near(register(ME), on([withdrawal()])), ME)).not.toThrow()
+ refused(near(register('evil.near'), on([withdrawal()])))
+ refused(near(register(ME, 'wrap.near'), on([withdrawal()])))
+ refused(near(on([withdrawal()]), on([withdrawal()])))
+ refused(near(register(ME), register(ME), on([withdrawal()])))
+ })
+
+ it('still refuses what it refused before', () => {
+ refused(near(on([sale()], 'evil.near')))
+ })
+})
+
+describe('confirming an exit', () => {
+ const executed = (status: unknown, receiver: string, method: string, args: object = {}) => ({
+ status,
+ transaction: {
+ signer_id: ME,
+ receiver_id: receiver,
+ actions: [
+ {
+ FunctionCall: {
+ method_name: method,
+ args: Buffer.from(JSON.stringify(args)).toString('base64'),
+ deposit: '0',
+ },
+ },
+ ],
+ },
+ })
+
+ async function stakeResult(venueId: string) {
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] })
+ const pending = confirmStake({
+ account: ME,
+ venueId,
+ signed: { family: 'near', hashes: [HASH] },
+ })
+ await vi.runAllTimersAsync()
+ return pending
+ }
+
+ async function earnResult() {
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] })
+ const pending = earnVenue.submit?.({
+ account: ME,
+ venueId: '1click-earn',
+ signed: { family: 'near', hashes: [HASH] },
+ context: { step: 'claim' },
+ })
+ await vi.runAllTimersAsync()
+ return pending
+ }
+
+ it.each([
+ ['metapool', METAPOOL_CONTRACT, 'unstake'],
+ ['metapool', METAPOOL_CONTRACT, 'unstake_all'],
+ ['metapool', METAPOOL_CONTRACT, 'withdraw_unstaked'],
+ ['rnear', RNEAR_CONTRACT, 'unstake'],
+ ['rnear', RNEAR_CONTRACT, 'unstake_all'],
+ ['rnear', RNEAR_CONTRACT, 'withdraw_all'],
+ ])('accepts %s %s %s', async (venueId, contract, method) => {
+ fake.tx = executed({ SuccessValue: '' }, contract, method)
+ await expect(stakeResult(venueId)).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('refuses another venue’s method, and says what a failure left behind', async () => {
+ fake.tx = executed({ SuccessValue: '' }, METAPOOL_CONTRACT, 'withdraw_all')
+ expect(await stakeResult('metapool')).toMatchObject({ ok: false })
+ fake.tx = executed({ Failure: {} }, METAPOOL_CONTRACT, 'unstake_all')
+ const unstake = await stakeResult('metapool')
+ expect(!unstake.ok && unstake.error).toMatch(/unstake failed.*still hold/)
+ fake.tx = executed({ Failure: {} }, METAPOOL_CONTRACT, 'withdraw_unstaked')
+ const claim = await stakeResult('metapool')
+ expect(!claim.ok && claim.error).toMatch(/claim failed.*still waiting/)
+ })
+
+ const withdrawn = { token: USDC_NEAR, receiver_id: ME, amount: '5' }
+
+ it('accepts a collection once the balance has dropped', async () => {
+ fake.tx = executed({ SuccessValue: '' }, INTENTS_CONTRACT, 'ft_withdraw', withdrawn)
+ await expect(earnResult()).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('does not call it done when the USDC is still in the balance', async () => {
+ fake.tx = executed({ SuccessValue: '' }, INTENTS_CONTRACT, 'ft_withdraw', withdrawn)
+ fake.balances[USDC_ASSET] = '5'
+ const result = await earnResult()
+ expect(result).toMatchObject({ ok: false, pending: true })
+ expect(result !== undefined && !result.ok && result.error).toMatch(/still shows/)
+ })
+
+ it('refuses a hash that paid someone else', async () => {
+ fake.tx = executed({ SuccessValue: '' }, INTENTS_CONTRACT, 'ft_withdraw', {
+ ...withdrawn,
+ receiver_id: 'evil.near',
+ })
+ await expect(earnResult()).resolves.toMatchObject({ ok: false, pending: true })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-safety.test.ts b/apps/dapp/lib/__tests__/near-safety.test.ts
index b4f1ecca..8e1acf3b 100644
--- a/apps/dapp/lib/__tests__/near-safety.test.ts
+++ b/apps/dapp/lib/__tests__/near-safety.test.ts
@@ -156,11 +156,13 @@ describe('assertOneClickSafe refuses tampering', () => {
refused(() => assertOneClickSafe(payload(USDC_NEAR, [noAmount]), ME))
})
- it('refuses a zero, malformed or oversized transfer', () => {
+ it('refuses a zero or malformed transfer, and takes one of any size', () => {
refused(() => assertOneClickSafe(payload(USDC_NEAR, [transfer('0')]), ME))
refused(() => assertOneClickSafe(payload(USDC_NEAR, [transfer('-1')]), ME))
refused(() => assertOneClickSafe(payload(USDC_NEAR, [transfer('1.5')]), ME))
- refused(() => assertOneClickSafe(payload(USDC_NEAR, [transfer('10000000001')]), ME))
+ expect(() =>
+ assertOneClickSafe(payload(USDC_NEAR, [transfer('10000000000000')]), ME)
+ ).not.toThrow()
})
it('refuses a storage payment of the wrong size or for a stranger', () => {
@@ -312,13 +314,10 @@ describe('the staking checks', () => {
)
})
- it('refuses a stake of nothing, or beyond the ceiling of 1,000 NEAR', () => {
+ it('refuses a stake of nothing, and takes one of any size', () => {
refused(() => assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('0')]), ME))
- refused(() =>
- assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('1000000000000000000000000001')]), ME)
- )
expect(() =>
- assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('1000000000000000000000000000')]), ME)
+ assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('1000000000000000000000000001')]), ME)
).not.toThrow()
})
diff --git a/apps/dapp/lib/__tests__/near-stake.test.ts b/apps/dapp/lib/__tests__/near-stake.test.ts
index 74d8049d..e510ce72 100644
--- a/apps/dapp/lib/__tests__/near-stake.test.ts
+++ b/apps/dapp/lib/__tests__/near-stake.test.ts
@@ -198,30 +198,26 @@ describe('what both stake venues refuse', () => {
describe('the module', () => {
it('stakes with Meta Pool by default, and with rNEAR when named', async () => {
- const env = { MAINNET_MAX_TRADE_USD: '500' }
- await expect(prepareChainAction(nearModule, stake(), ME, env)).resolves.toMatchObject({
+ await expect(prepareChainAction(nearModule, stake(), ME)).resolves.toMatchObject({
venueId: 'metapool',
})
await expect(
- prepareChainAction(nearModule, stake({ venue: 'rNEAR' }), ME, env)
+ prepareChainAction(nearModule, stake({ venue: 'rNEAR' }), ME)
).resolves.toMatchObject({ venueId: 'rnear' })
await expect(
- prepareChainAction(nearModule, stake({ venue: 'meta pool' }), ME, env)
+ prepareChainAction(nearModule, stake({ venue: 'meta pool' }), ME)
).resolves.toMatchObject({ venueId: 'metapool' })
})
- it('refuses a stake over the cap, and a venue that cannot stake', async () => {
- const env = { MAINNET_MAX_TRADE_USD: '500' }
- await expect(prepareChainAction(nearModule, stake({ amount: '126' }), ME, env)).rejects.toThrow(
- /caps each trade/
+ it('refuses a venue that cannot stake', async () => {
+ await expect(prepareChainAction(nearModule, stake({ venue: '1click' }), ME)).rejects.toThrow(
+ /can't stake here/
)
- await expect(
- prepareChainAction(nearModule, stake({ venue: '1click' }), ME, env)
- ).rejects.toThrow(/can't stake here/)
})
- it('has exactly the allowlisted tokens at the confirmed decimals', () => {
- expect(nearModule.tokens.map((t) => [t.symbol, t.id, t.decimals])).toEqual([
+ it('has the allowlisted tokens at the confirmed decimals', () => {
+ const spendable = nearModule.tokens.filter((t) => ['NEAR', 'USDC', 'USDT'].includes(t.symbol))
+ expect(spendable.map((t) => [t.symbol, t.id, t.decimals])).toEqual([
['NEAR', 'wrap.near', 24],
['USDC', '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1', 6],
['USDT', 'usdt.tether-token.near', 6],
diff --git a/apps/dapp/lib/__tests__/near-swap.test.ts b/apps/dapp/lib/__tests__/near-swap.test.ts
index f6692ad3..231ebb4b 100644
--- a/apps/dapp/lib/__tests__/near-swap.test.ts
+++ b/apps/dapp/lib/__tests__/near-swap.test.ts
@@ -490,35 +490,19 @@ describe('what 1Click can refuse', () => {
})
describe('the whole prepare path for NEAR', () => {
- const noCap = { MAINNET_MAX_TRADE_USD: '500' }
-
- it('defaults swaps to 1Click', async () => {
- const prepared = await prepareChainAction(nearModule, swap(), ME, noCap)
+ it('defaults swaps to Rhea, and reaches 1Click when named', async () => {
+ expect(nearModule.defaultVenue.swap).toBe('rhea-dex')
+ const prepared = await prepareChainAction(nearModule, swap({ venue: '1click' }), ME)
expect(prepared.venueId).toBe('1click')
})
- it('refuses over the cap before asking for any quote', async () => {
- await expect(
- prepareChainAction(nearModule, swap({ amount: '501' }), ME, noCap)
- ).rejects.toThrow(/caps each trade/)
- await expect(
- prepareChainAction(
- nearModule,
- swap({ tokenIn: 'NEAR', tokenOut: 'USDC', amount: '126' }),
- ME,
- noCap
- )
- ).rejects.toThrow(/caps each trade/)
- expect(sim.quotes).toHaveLength(0)
- })
-
it('refuses an unknown token and a venue that cannot do this', async () => {
- await expect(
- prepareChainAction(nearModule, swap({ tokenIn: 'DOGE' }), ME, noCap)
- ).rejects.toThrow(/verified token/)
- await expect(
- prepareChainAction(nearModule, swap({ venue: 'rnear' }), ME, noCap)
- ).rejects.toThrow(/can't swap here/)
+ await expect(prepareChainAction(nearModule, swap({ tokenIn: 'DOGE' }), ME)).rejects.toThrow(
+ /verified token/
+ )
+ await expect(prepareChainAction(nearModule, swap({ venue: 'rnear' }), ME)).rejects.toThrow(
+ /can't swap here/
+ )
})
it('reads the price of the exact asset, not another with the same symbol', async () => {
@@ -534,13 +518,10 @@ describe('the whole prepare path for NEAR', () => {
).resolves.toBeUndefined()
})
- it('has no price when the token list fails, so the trade is refused', async () => {
+ it('has no price when the token list fails', async () => {
vi.mocked(fetch).mockImplementation(async () => new Response('nope', { status: 500 }))
const usdc = nearModule.tokens.find((t) => t.symbol === 'USDC')
await expect(nearModule.priceUsd(usdc as NonNullable)).resolves.toBeUndefined()
- await expect(prepareChainAction(nearModule, swap(), ME, noCap)).rejects.toThrow(
- /could not be valued/
- )
})
})
diff --git a/apps/dapp/lib/__tests__/near-venues.test.ts b/apps/dapp/lib/__tests__/near-venues.test.ts
index 8d0aa634..6a2d717f 100644
--- a/apps/dapp/lib/__tests__/near-venues.test.ts
+++ b/apps/dapp/lib/__tests__/near-venues.test.ts
@@ -52,9 +52,9 @@ describe('the NEAR venue list', () => {
}
})
- it('keeps Rhea Lend and the Linq route listed, and says why on the first', () => {
+ it('says what happened to Rhea Lend, and keeps the Linq route listed', () => {
const lend = nearVenues.find((v) => v.id === 'rhea-lend')
- expect(lend?.integration).toBeUndefined()
+ expect(lend?.integration).toBe('executes')
expect(lend?.bestFor).toMatch(/18\.4M/)
expect(nearVenues.find((v) => v.id === 'near-linq-cashout')?.integration).toBeUndefined()
})
@@ -108,9 +108,9 @@ describe('1Click Earn', () => {
}
const earn = nearModule.venues.find((v) => v.id === '1click-earn')
- it('is the default for supply and does nothing else', () => {
+ it('is the default for supply, and takes money back out', () => {
expect(nearModule.defaultVenue.supply).toBe('1click-earn')
- expect(earn?.kinds).toEqual(['supply'])
+ expect(earn?.kinds).toEqual(['supply', 'withdraw', 'claim'])
})
it('accepts a stablecoin deposit and refuses the wrap path a swap may use', () => {
diff --git a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts
index a567b763..77afb86a 100644
--- a/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts
+++ b/apps/dapp/lib/__tests__/orchestrate-capabilities-chain.test.ts
@@ -24,7 +24,7 @@ describe('capabilitySheet per chain', () => {
it('says agents compete on Solana and Sui now their venues can be quoted', () => {
expect(capabilitySheet('solana')).toContain('Agents compete')
expect(capabilitySheet('sui')).toContain('Agents compete')
- expect(capabilitySheet('solana')).not.toContain('Stake:')
+ expect(capabilitySheet('solana')).toContain('Stake:')
})
it('leaves a chain with no module as it was', () => {
@@ -48,21 +48,22 @@ describe('capabilitySheet reads what the chain can run on this server', () => {
return (await import('../orchestrate/capabilities')).capabilitySheet
}
- it('promises nothing on Avalanche and Monad with no secret to issue a deposit', async () => {
+ it('promises only the onchain venues on Avalanche and Monad with no secret', async () => {
const sheet = await sheetWith('')
for (const chain of ['avalanche', 'monad']) {
- expect(sheet(chain)).toBe(
- '- Nothing can be swapped, supplied or staked on this chain right now.'
- )
+ expect(sheet(chain)).toContain('Swap:')
+ expect(sheet(chain)).toContain('Supply:')
+ expect(sheet(chain)).not.toContain('Send:')
}
- })
+ }, 60_000)
it('promises the race on them once the secret is set', async () => {
const sheet = await sheetWith('x'.repeat(48))
for (const chain of ['avalanche', 'monad']) {
expect(sheet(chain)).toContain('Swap:')
expect(sheet(chain)).toContain('Agents compete')
- expect(sheet(chain)).not.toMatch(/Supply:|Stake:/)
+ expect(sheet(chain)).toContain('Supply:')
+ expect(sheet(chain)).toContain('Borrow and repay:')
}
}, 60_000)
@@ -123,12 +124,13 @@ describe('capabilitySheet multi-step forms', () => {
expect(near).toMatch(/stake after a swap is not available on NEAR/i)
}, 60_000)
- it('says Monad runs swap and send, and not cash-out or deposit', async () => {
+ it('says Monad runs swap then deposit or send, and not cash-out', async () => {
const sheet = await sheetWith('k')
const monad = sheet('monad')
expect(monad).toMatch(/Swap then send:/)
- expect(monad).not.toMatch(/Swap then (cash out|deposit):/)
- expect(monad).toMatch(/Cash out and deposit after a swap do not run on Monad/)
+ expect(monad).toMatch(/Swap then deposit:/)
+ expect(monad).not.toMatch(/Swap then cash out:/)
+ expect(monad).toMatch(/Cash out does not run from Monad/)
}, 60_000)
it('says which native coins cannot be sent yet', async () => {
@@ -179,9 +181,9 @@ describe('the orchestrator prompt', () => {
it('is byte for byte what it was on Stellar', async () => {
vi.stubEnv('WEAVE_SECRET_KEY', '')
const system = await systemFor('stellar')
- expect(system.length).toBe(6270)
+ expect(system.length).toBe(6452)
expect(createHash('sha256').update(system).digest('hex')).toBe(
- '06762643ae6b219a91581fc48e65b67563bb70062f2761e60a8a5d3aeae9be1b'
+ 'a6ed692eb9f9ebc325842f13f0b6417c6b4ee970922535a1fa9c8c497c63edfc'
)
})
diff --git a/apps/dapp/lib/__tests__/parse-exit.test.ts b/apps/dapp/lib/__tests__/parse-exit.test.ts
new file mode 100644
index 00000000..50721542
--- /dev/null
+++ b/apps/dapp/lib/__tests__/parse-exit.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest'
+
+import { parseStellarExit, whereReply } from '../lend/parse-exit'
+import { parseCashout } from '../weave/parse-cashout'
+
+describe('reading a way out of Blend', () => {
+ it.each([
+ ['withdraw 25 XLM', { kind: 'withdraw', symbol: 'XLM', amount: '25' }],
+ ['Withdraw 25 XLM.', { kind: 'withdraw', symbol: 'XLM', amount: '25' }],
+ ['please withdraw 1,000.5 usdc', { kind: 'withdraw', symbol: 'USDC', amount: '1000.5' }],
+ ['withdraw .5 xlm from blend', { kind: 'withdraw', symbol: 'XLM', amount: '0.5' }],
+ ['redeem 3 USDC from the blend pool', { kind: 'withdraw', symbol: 'USDC', amount: '3' }],
+ ['take 25 XLM out of Blend', { kind: 'withdraw', symbol: 'XLM', amount: '25' }],
+ ['withdraw all XLM', { kind: 'withdraw', symbol: 'XLM' }],
+ ['withdraw all my usdc from blend', { kind: 'withdraw', symbol: 'USDC' }],
+ ['withdraw my lumens', { kind: 'withdraw', symbol: 'XLM' }],
+ ['get my USDC back out of Blend', { kind: 'withdraw', symbol: 'USDC' }],
+ ['repay 5 USDC', { kind: 'repay', symbol: 'USDC', amount: '5' }],
+ ['pay back all my USDC', { kind: 'repay', symbol: 'USDC' }],
+ ['repay my usdc loan on blend', { kind: 'repay', symbol: 'USDC' }],
+ ['take my money out of blend', { kind: 'where' }],
+ ['withdraw from Blend', { kind: 'where' }],
+ ['close my blend position', { kind: 'where' }],
+ ['repay my loan', { kind: 'where' }],
+ ['repay all', { kind: 'where' }],
+ ])('%s', (text, expected) => {
+ expect(parseStellarExit(text)).toEqual(expected)
+ })
+
+ it.each([
+ 'withdraw 5 USDC to my bank',
+ 'withdraw my USDC to my bank',
+ 'withdraw 5 dollars',
+ 'withdraw 0 XLM',
+ 'withdraw 25',
+ 'withdraw 25 XLM from Aquarius',
+ 'swap 25 XLM to USDC',
+ 'sell 25 XLM',
+ 'how do I withdraw 25 XLM from blend?',
+ 'cash out 5 USDC',
+ 'supply 25 XLM to blend',
+ 'repay',
+ ])('leaves alone: %s', (text) => {
+ expect(parseStellarExit(text)).toBeNull()
+ })
+
+ it('leaves a bank withdrawal to the cash-out reader, which still takes it', () => {
+ expect(parseStellarExit('withdraw 5 USDC to my bank')).toBeNull()
+ expect(parseCashout('withdraw 5 USDC to my bank')).not.toBeNull()
+ expect(parseCashout('withdraw 25 XLM')).toBeNull()
+ })
+})
+
+describe('saying what is in Blend', () => {
+ const xlm = { display: '12.5000000', symbol: 'XLM' }
+ const usdc = { display: '4.0000000', symbol: 'USDC' }
+
+ it('lists what is there and how to take it', () => {
+ const reply = whereReply({ supplied: [xlm], collateral: [usdc], borrowed: [] })
+ expect(reply).toContain('12.5 XLM supplied and 4 USDC as collateral')
+ expect(reply).toContain('withdraw all XLM')
+ })
+
+ it('says a loan holds collateral back', () => {
+ expect(whereReply({ supplied: [], collateral: [xlm], borrowed: [usdc] })).toContain(
+ 'You also owe 4 USDC'
+ )
+ })
+
+ it('says when there is nothing', () => {
+ expect(whereReply({ supplied: [], collateral: [], borrowed: [] })).toBe(
+ 'You have nothing in Blend to withdraw.'
+ )
+ })
+})
diff --git a/apps/dapp/lib/__tests__/pharaoh.live.test.ts b/apps/dapp/lib/__tests__/pharaoh.live.test.ts
new file mode 100644
index 00000000..01d83754
--- /dev/null
+++ b/apps/dapp/lib/__tests__/pharaoh.live.test.ts
@@ -0,0 +1,156 @@
+import { formatUnits, parseAbi, parseUnits, type Address } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { evmReader } from '../chains/evm/client'
+import { createPharaohVenue, pharaohRouter } from '../chains/evm/pharaoh'
+import { ChainActionRefused } from '../chains/types'
+import { WAVAX } from '../chains/evm/v3-swap'
+import { fakeClient, fundedReader, wethHolding } from './v3-swap-suite'
+
+/**
+ * The real contracts, read-only. Nothing signs or sends: the swaps below are `eth_call`s from an
+ * account that only exists inside the call, given a balance by a state override.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const ME = '0x00000000000000000000000000000000000feed1'
+const MUCH = BigInt('1000000000000000000000000')
+const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
+const DEPLOYER = '0x6a4113ed0915bCf5E48e758e8f4cEBFFC07C66f9'
+const FACTORY = '0xAE6E5c62328ade73ceefD42228528b70c8157D0d'
+
+const IMMUTABLES = parseAbi([
+ 'function deployer() view returns (address)',
+ 'function WETH9() view returns (address)',
+])
+const FACTORY_ABI = parseAbi([
+ 'function ramsesV3PoolDeployer() view returns (address)',
+ 'function tickSpacingInitialFee(int24) view returns (uint24)',
+])
+
+describe.skipIf(SKIP)('Pharaoh, live', () => {
+ const router = pharaohRouter()
+ const reader = evmReader('avalanche')
+
+ it.each([
+ ['router', router.router],
+ ['quoter', router.quoter],
+ ])(
+ 'the %s answers with the docs’ deployer and wrapped coin',
+ async (_label, address) => {
+ const [deployer, weth] = await Promise.all([
+ reader.readContract({
+ address: address as Address,
+ abi: IMMUTABLES,
+ functionName: 'deployer',
+ }),
+ reader.readContract({
+ address: address as Address,
+ abi: IMMUTABLES,
+ functionName: 'WETH9',
+ }),
+ ])
+ expect(deployer.toLowerCase()).toBe(DEPLOYER.toLowerCase())
+ expect(weth.toLowerCase()).toBe(WAVAX.id.toLowerCase())
+ },
+ 30_000
+ )
+
+ it('the factory names that deployer', async () => {
+ const deployer = await reader.readContract({
+ address: FACTORY,
+ abi: FACTORY_ABI,
+ functionName: 'ramsesV3PoolDeployer',
+ })
+ expect(deployer.toLowerCase()).toBe(DEPLOYER.toLowerCase())
+ }, 30_000)
+
+ it('the router holds the functions this venue calls, and is no proxy', async () => {
+ const [code, implementation] = await Promise.all([
+ reader.getCode({ address: router.router as Address }),
+ reader.getStorageAt({ address: router.router as Address, slot: IMPLEMENTATION_SLOT }),
+ ])
+ expect(code).toContain('ac9650d8')
+ expect(code).toContain('12210e8a')
+ expect(BigInt(implementation ?? '0x0')).toBe(BigInt(0))
+ }, 30_000)
+
+ it('every tick spacing is switched on in the factory', async () => {
+ for (const tier of router.tiers) {
+ const fee = await reader.readContract({
+ address: FACTORY,
+ abi: FACTORY_ABI,
+ functionName: 'tickSpacingInitialFee',
+ args: [tier],
+ })
+ expect(fee).toBeGreaterThan(0)
+ }
+ }, 30_000)
+
+ it('quotes a real price', async () => {
+ const quoted = await createPharaohVenue().quote?.(
+ { kind: 'swap', amount: '20', tokenIn: 'USDC', tokenOut: 'WAVAX' },
+ ME
+ )
+ expect(Number(quoted?.receive?.amount)).toBeGreaterThan(0)
+ }, 30_000)
+
+ it('swaps AVAX in, wrapped by the router, in a call from a funded account', async () => {
+ const venue = createPharaohVenue({
+ reader: () => fundedReader(reader, [{ address: ME as Address, balance: MUCH }], BigInt(0)),
+ client: () => fakeClient(),
+ })
+ // `prepare` runs the call against the chain and refuses if it would fail.
+ const prepared = await venue.prepare(
+ { kind: 'swap', amount: '1', tokenIn: 'AVAX', tokenOut: 'USDC' },
+ ME
+ )
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ }, 60_000)
+
+ it('swaps WAVAX in, pulled under an exact allowance, in a call from a funded account', async () => {
+ const units = parseUnits('2', WAVAX.decimals)
+ const venue = createPharaohVenue({
+ reader: () =>
+ fundedReader(
+ reader,
+ [
+ { address: ME as Address, balance: MUCH },
+ { address: WAVAX.id as Address, stateDiff: wethHolding(ME, router.router, units) },
+ ],
+ units
+ ),
+ client: () => fakeClient(),
+ })
+ const prepared = await venue.prepare(
+ {
+ kind: 'swap',
+ amount: formatUnits(units, WAVAX.decimals),
+ tokenIn: 'WAVAX',
+ tokenOut: 'USDC',
+ },
+ ME
+ )
+ expect(prepared.signing).not.toHaveProperty('approval')
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ }, 60_000)
+
+ it('refuses that same swap from an account that holds none, so the two above prove something', async () => {
+ const units = parseUnits('2', WAVAX.decimals)
+ const venue = createPharaohVenue({
+ reader: () => fundedReader(reader, [{ address: ME as Address, balance: MUCH }], units),
+ client: () => fakeClient(),
+ })
+ await expect(
+ venue.prepare(
+ {
+ kind: 'swap',
+ amount: formatUnits(units, WAVAX.decimals),
+ tokenIn: 'WAVAX',
+ tokenOut: 'USDC',
+ },
+ ME
+ )
+ ).rejects.toThrow(ChainActionRefused)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/pharaoh.test.ts b/apps/dapp/lib/__tests__/pharaoh.test.ts
new file mode 100644
index 00000000..89499aaa
--- /dev/null
+++ b/apps/dapp/lib/__tests__/pharaoh.test.ts
@@ -0,0 +1,113 @@
+import {
+ decodeFunctionData,
+ encodeFunctionData,
+ parseAbi,
+ toFunctionSelector,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { createPharaohVenue } from '../chains/evm/pharaoh'
+import {
+ ACCOUNT,
+ describeV3SwapVenue,
+ fakeClient,
+ fakeReader,
+ type RouterFixture,
+ type SwapFields,
+} from './v3-swap-suite'
+
+// Written out by hand from the source Routescan verifies at this router, apart from the code under test.
+const ROUTER_ABI = parseAbi([
+ 'struct Params { address tokenIn; address tokenOut; int24 tickSpacing; address recipient; uint256 deadline; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }',
+ 'function exactInputSingle(Params params) payable returns (uint256 amountOut)',
+ 'function multicall(bytes[] data) payable returns (bytes[] results)',
+])
+
+function encodeSwap(fields: SwapFields): Hex {
+ return encodeFunctionData({
+ abi: ROUTER_ABI,
+ functionName: 'exactInputSingle',
+ args: [
+ {
+ tokenIn: fields.tokenIn as Address,
+ tokenOut: fields.tokenOut as Address,
+ tickSpacing: fields.tier,
+ recipient: fields.recipient as Address,
+ deadline: fields.deadline,
+ amountIn: fields.amountIn,
+ amountOutMinimum: fields.amountOutMinimum,
+ sqrtPriceLimitX96: fields.sqrtPriceLimitX96,
+ },
+ ],
+ })
+}
+
+function decodeSwap(data: Hex): SwapFields & { outer: Hex[] } {
+ const outer = decodeFunctionData({ abi: ROUTER_ABI, data })
+ if (outer.functionName !== 'multicall') throw new Error('not a multicall')
+ const [calls] = outer.args
+ const inner = decodeFunctionData({ abi: ROUTER_ABI, data: calls[0] as Hex })
+ if (inner.functionName !== 'exactInputSingle') throw new Error('not a swap')
+ const [params] = inner.args
+ return {
+ tokenIn: params.tokenIn,
+ tokenOut: params.tokenOut,
+ tier: params.tickSpacing,
+ recipient: params.recipient,
+ amountIn: params.amountIn,
+ amountOutMinimum: params.amountOutMinimum,
+ sqrtPriceLimitX96: params.sqrtPriceLimitX96,
+ deadline: params.deadline,
+ outer: [...calls],
+ }
+}
+
+const PHARAOH: RouterFixture = {
+ label: 'Pharaoh',
+ venue: (reader, client) => createPharaohVenue({ reader: () => reader, client: () => client }),
+ chainId: 43114,
+ otherChainId: 143,
+ router: '0xc8B8fCbDb5C019D7802fFb0b39603395D7d3915c',
+ quoter: '0xB7297301b7CC659BB96D51754643A0Df6eEA2138',
+ nativeSymbol: 'AVAX',
+ wrapped: { symbol: 'WAVAX', id: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7' },
+ stable: { symbol: 'USDC', id: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E' },
+ stable2: { symbol: 'USDT', id: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7' },
+ bestTier: 10,
+ worseTier: 1,
+ unverifiedTier: 500,
+ tierOf: (params) => params['tickSpacing'] as number,
+ encodeSwap,
+ encodeOuter: (calls) =>
+ encodeFunctionData({ abi: ROUTER_ABI, functionName: 'multicall', args: [calls] }),
+ deadlineIn: 'swap',
+ decodeSwap,
+}
+
+describeV3SwapVenue(PHARAOH)
+
+describe('Pharaoh encoding', () => {
+ it('uses the multicall selector in the deployed bytecode', () => {
+ expect(toFunctionSelector(ROUTER_ABI[1])).toBe('0xac9650d8')
+ })
+
+ it('only swaps, under its one id', () => {
+ expect(createPharaohVenue().id).toBe('pharaoh')
+ expect(createPharaohVenue().kinds).toEqual(['swap'])
+ })
+
+ it('says on the card that its audits are not of its own deployment', async () => {
+ const { reader } = fakeReader(PHARAOH, { rate: { 10: BigInt(900_000) } })
+ const venue = createPharaohVenue({ reader: () => reader, client: () => fakeClient() })
+ const prepared = await venue.prepare(
+ { kind: 'swap', amount: '5', tokenIn: 'USDC', tokenOut: 'USDT' },
+ ACCOUNT
+ )
+ const notes = prepared.notes.join(' ')
+ expect(notes).toContain('not a review of Pharaoh’s deployment')
+ expect(notes).toContain('about $93,000')
+ expect(notes).toContain('change a pool’s fee')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/positions-reply.test.ts b/apps/dapp/lib/__tests__/positions-reply.test.ts
new file mode 100644
index 00000000..8c529563
--- /dev/null
+++ b/apps/dapp/lib/__tests__/positions-reply.test.ts
@@ -0,0 +1,412 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { fixVerbTypo } from '../chat/typos'
+import { parseChainAction } from '../chains/parse-action'
+import { readChainMessage } from '../chains/parse-sequence'
+import {
+ asksForPositions,
+ blendPositionsReply,
+ positionsReply,
+ readPositionsReply,
+ type PositionsReport,
+ type VenueHoldings,
+} from '../chains/positions-reply'
+import type { BlendPositions } from '../lend/position'
+import type { ChainActionKind, VenuePosition } from '../chains/types'
+import { parseCashout } from '../weave/parse-cashout'
+
+const ASKED = [
+ 'what have I deposited',
+ 'What have I deposited?',
+ 'where is my money',
+ "where's my money?",
+ 'where’s my money',
+ 'wheres my money',
+ 'where are my funds',
+ 'where did my money go',
+ 'my positions',
+ 'My Positions.',
+ 'show my positions',
+ 'show me my positions',
+ 'can you show me my positions please',
+ 'tell me my positions',
+ 'tell me where my money is',
+ 'what can I withdraw',
+ 'what can I withdraw from Aave',
+ 'how much can I withdraw',
+ 'how much USDC can I withdraw',
+ 'what could I unstake',
+ 'what can I claim',
+ "what's ready to claim",
+ 'anything to claim',
+ 'is anything ready to claim?',
+ 'what is available to withdraw',
+ 'how much have I deposited',
+ 'how much USDC did I supply',
+ 'what did I deposit',
+ 'what do I have staked',
+ 'how much do I have staked right now',
+ 'show me what I have deposited so far',
+ "show me what I've supplied",
+ 'show me what I deposited',
+ 'what have I borrowed',
+ 'what do I owe',
+ 'how much do I owe on Aave',
+ 'do I have any deposits',
+ 'do I have anything staked',
+ 'do I have USDC staked',
+ "what's staked",
+ "what's cooling",
+ 'my deposits',
+ 'my staked SOL',
+ 'my loans',
+ "what's my position",
+ 'what are my open positions',
+ 'open positions',
+ 'positions',
+]
+
+const NOT_ASKED = [
+ // Plain balance questions, which the model answers from the wallet.
+ 'what is my balance',
+ "what's my balance",
+ 'my balance',
+ 'show my balance',
+ 'show me my tokens',
+ 'my tokens',
+ 'my assets',
+ 'show my assets',
+ 'my holdings',
+ 'my portfolio',
+ 'what do I have',
+ 'what do I hold',
+ 'what have I got',
+ 'how much do I have',
+ 'how much USDC do I have',
+ 'how much money do I have',
+ "what's in my wallet",
+ 'what tokens do I have',
+ 'where is my wallet',
+ // Instructions, which must stay instructions.
+ 'withdraw all USDC from Aave',
+ 'withdraw my USDC',
+ 'withdraw 5 USDC to my bank',
+ 'deposit 5 USDC on Aave',
+ 'stake 5 SOL',
+ 'unstake all SOL',
+ 'repay all USDC on Aave',
+ 'claim SOL from Marinade',
+ 'close my positions',
+ 'get my deposits',
+ 'give me my deposit',
+ 'sell me my positions',
+ 'what can I withdraw to my bank',
+ 'what have I deposited and swap 5 USDC to SOL',
+ // Other questions.
+ 'how do I withdraw',
+ 'what can I do',
+ 'what can I swap',
+ 'what can I stake',
+ 'how much can I borrow',
+ 'how much should I stake',
+ 'how much will I save',
+ 'what is staking',
+ 'what is staked SOL',
+ 'what is a deposit',
+ "what's the price of SOL",
+ 'I want to go jogging',
+ '',
+]
+
+describe('asksForPositions', () => {
+ it.each(ASKED)('hears %j', (text) => {
+ expect(asksForPositions(text)).toBe(true)
+ })
+
+ it.each(NOT_ASKED)('leaves %j alone', (text) => {
+ expect(asksForPositions(text)).toBe(false)
+ })
+
+ it.each(ASKED)('nothing that runs first takes %j as an instruction', (text) => {
+ expect(parseCashout(text)).toBeNull()
+ expect(readChainMessage(text, 'avalanche', 'Avalanche')).toEqual({ kind: 'model' })
+ })
+
+ it('leaves "tell" alone, since one letter from "sell" is not a typo of it', () => {
+ expect(fixVerbTypo('tell me my positions')).toBe('tell me my positions')
+ })
+})
+
+const position = (over: Partial & Pick): VenuePosition => ({
+ symbol: 'USDC',
+ amount: '100.5',
+ ...over,
+})
+
+const EXITS: readonly ChainActionKind[] = ['withdraw', 'unstake', 'repay', 'claim', 'cancel']
+
+const venue = (name: string, positions: VenuePosition[], kinds = EXITS): VenueHoldings => ({
+ id: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
+ name,
+ kinds,
+ positions,
+})
+
+const report = (venues: VenueHoldings[], unread: string[] = []): PositionsReport => ({
+ venues,
+ unread: unread.map((name) => ({ id: name.toLowerCase(), name })),
+})
+
+/** The sentences a reply offers, read back out of it. */
+const offered = (reply: string): string[] =>
+ [...reply.matchAll(/type "([^"]+)"/g)].map((m) => m[1] as string)
+
+describe('positionsReply', () => {
+ it('says what is supplied, staked, borrowed, cooling and ready, each with the words to type', () => {
+ const reply = positionsReply(
+ report([
+ venue('Aave', [
+ position({ slot: 'supplied' }),
+ position({ slot: 'borrowed', amount: '20', symbol: 'USDT' }),
+ ]),
+ venue('Benqi', [
+ position({ slot: 'staked', amount: '5', symbol: 'sAVAX' }),
+ position({ slot: 'cooling', amount: '1', symbol: 'AVAX', readyInHours: 30 }),
+ position({ slot: 'claimable', amount: '2', symbol: 'AVAX' }),
+ ]),
+ ]),
+ 'Avalanche'
+ )
+ expect(reply).toContain('• 100.5 USDC on Aave: type "withdraw all USDC from Aave"')
+ expect(reply).toContain('• 5 sAVAX on Benqi: type "unstake all sAVAX from Benqi"')
+ expect(reply).toContain('• 20 USDT on Aave: type "repay all USDT on Aave"')
+ expect(reply).toContain(
+ '• 1 AVAX on Benqi, ready in about 30 hours. Then type "claim AVAX from Benqi"'
+ )
+ expect(reply).toContain('• 2 AVAX on Benqi: type "claim AVAX from Benqi"')
+ const order = ['Supplied', 'Staked', 'Borrowed', 'Cooling down', 'Ready to claim']
+ const at = order.map((heading) => reply.indexOf(`\n${heading}\n`))
+ expect(at.every((index) => index > 0)).toBe(true)
+ expect([...at].sort((a, b) => a - b)).toEqual(at)
+ })
+
+ it('offers only sentences the chat reads back as that exit, at that venue', () => {
+ const reply = positionsReply(
+ report([
+ venue('Jupiter Lend Earn', [
+ position({ slot: 'supplied', symbol: 'USDC.e' }),
+ position({ slot: 'staked', symbol: 'JitoSOL' }),
+ position({ slot: 'borrowed', symbol: 'USDT' }),
+ position({ slot: 'claimable', symbol: 'SOL' }),
+ position({ slot: 'supplied', symbol: 'USDC', id: 'abc123' }),
+ ]),
+ ]),
+ 'Solana'
+ )
+ const parsed = offered(reply).map(parseChainAction)
+ expect(parsed).toMatchObject([
+ { kind: 'withdraw', amount: 'all', tokenIn: 'USDC.e', venue: 'Jupiter Lend Earn' },
+ { kind: 'cancel', streamId: 'abc123', venue: 'Jupiter Lend Earn' },
+ { kind: 'unstake', amount: 'all', tokenIn: 'JitoSOL', venue: 'Jupiter Lend Earn' },
+ { kind: 'repay', amount: 'all', tokenIn: 'USDT', venue: 'Jupiter Lend Earn' },
+ { kind: 'claim', amount: 'all', tokenIn: 'SOL', venue: 'Jupiter Lend Earn' },
+ ])
+ for (const words of offered(reply)) expect(parseCashout(words)).toBeNull()
+ })
+
+ it('offers no sentence a venue could not run', () => {
+ const reply = positionsReply(
+ report([
+ venue('Supply Only', [position({ slot: 'supplied' })], ['supply']),
+ venue('No Cancel', [position({ slot: 'supplied', id: 'abc' })], ['withdraw']),
+ ]),
+ 'Monad'
+ )
+ expect(offered(reply)).toEqual([])
+ expect(reply).toContain("• 100.5 USDC on Supply Only. I can't act on this one from here yet.")
+ expect(reply).toContain("• 100.5 USDC on No Cancel. I can't act on this one from here yet.")
+ })
+
+ it('falls back to the id, then to no venue, when a name could not be typed', () => {
+ const symbols = positionsReply(
+ report([venue('Odd (Name)', [position({ slot: 'supplied' })])]),
+ 'Sui'
+ )
+ expect(offered(symbols)).toEqual(['withdraw all USDC from odd-name-'])
+ const nameless = positionsReply(
+ report([
+ { id: '#', name: '(?)', kinds: ['withdraw'], positions: [position({ slot: 'supplied' })] },
+ ]),
+ 'Sui'
+ )
+ expect(offered(nameless)).toEqual(['withdraw all USDC'])
+ })
+
+ it.each([
+ [0.5, 'ready in under an hour'],
+ [1.2, 'ready in about an hour'],
+ [6, 'ready in about 6 hours'],
+ [120, 'ready in about 5 days'],
+ ])('says when a cooling exit of %d hours is ready', (readyInHours, said) => {
+ const reply = positionsReply(
+ report([venue('Benqi', [position({ slot: 'cooling', readyInHours })])]),
+ 'Avalanche'
+ )
+ expect(reply).toContain(said)
+ })
+
+ it('says so when the chain gives no time', () => {
+ const reply = positionsReply(report([venue('Benqi', [position({ slot: 'cooling' })])]), 'Sui')
+ expect(reply).toContain(`doesn't say when it's ready`)
+ expect(reply).toContain('Then type "claim USDC from Benqi"')
+ })
+
+ it('names what it could not read beside what it could', () => {
+ const reply = positionsReply(
+ report([venue('Aave', [position({ slot: 'supplied' })])], ['Morpho', 'Euler']),
+ 'Avalanche'
+ )
+ expect(reply).toContain('on Aave')
+ expect(reply).toContain("I couldn't read Morpho, Euler, so I can't say what you have there.")
+ })
+
+ it('says nothing is there only when everything was read', () => {
+ const empty = positionsReply(report([venue('Aave', [])]), 'Avalanche')
+ expect(empty).toContain('You have nothing deposited')
+ expect(empty).not.toContain("couldn't")
+
+ const partly = positionsReply(report([venue('Aave', [])], ['Morpho']), 'Avalanche')
+ expect(partly).not.toContain('You have nothing')
+ expect(partly).toContain('Nothing shows on the apps I could read on Avalanche.')
+ expect(partly).toContain("I couldn't read Morpho")
+
+ const none = positionsReply(report([], ['Morpho']), 'Avalanche')
+ expect(none).not.toMatch(/nothing/i)
+ expect(none).toContain("I couldn't read Morpho")
+ })
+
+ it('never reads a failed or empty answer as nothing held', () => {
+ for (const reply of [positionsReply(undefined, 'Sui'), positionsReply(report([]), 'Sui')]) {
+ expect(reply).toContain("I couldn't read your positions")
+ expect(reply).not.toContain('You have nothing')
+ }
+ })
+
+ it('says the wallet is a separate question, in words that reach the balance path', () => {
+ const reply = positionsReply(report([venue('Aave', [])]), 'Sui')
+ expect(reply).toContain(`Ask "what's my balance"`)
+ expect(asksForPositions("what's my balance")).toBe(false)
+ })
+})
+
+const blendBalance = (symbol: string, display: string) => ({
+ assetId: 'C',
+ symbol,
+ tokens: '1',
+ amount: '1',
+ display,
+ reserveIndex: 0,
+})
+
+const blend = (over: Partial): BlendPositions => ({
+ supplied: [],
+ collateral: [],
+ borrowed: [],
+ health: { collateralBase: 0, liabilityBase: 0, healthFactor: undefined, liquidatable: false },
+ liquidationPrice: undefined,
+ ...over,
+})
+
+describe('blendPositionsReply', () => {
+ it('keeps supply and collateral apart, and points at where they are acted on', () => {
+ const reply = blendPositionsReply(
+ blend({
+ supplied: [blendBalance('XLM', '100.5000000')],
+ collateral: [blendBalance('XLM', '50.0000000')],
+ borrowed: [blendBalance('CABC…WXYZ', '10.0000001')],
+ })
+ )
+ expect(reply).toContain('Supplied\n• 100.5 XLM on Blend\n')
+ expect(reply).toContain('Collateral\n• 50 XLM on Blend. It can be liquidated')
+ expect(reply).toContain('Borrowed\n• 10.0000001 CABC…WXYZ on Blend\n')
+ expect(reply).toContain('open the History tab')
+ expect(reply).not.toContain('can be liquidated now')
+ })
+
+ it('warns when the position can be liquidated', () => {
+ const reply = blendPositionsReply(
+ blend({
+ collateral: [blendBalance('XLM', '50.0000000')],
+ health: { collateralBase: 1, liabilityBase: 2, healthFactor: 0.5, liquidatable: true },
+ })
+ )
+ expect(reply).toContain('Your Blend position can be liquidated now.')
+ })
+
+ it('says nothing is in Blend when it was read, and not that nothing is on Stellar', () => {
+ const reply = blendPositionsReply(blend({}))
+ expect(reply).toContain('You have nothing supplied or borrowed on Blend.')
+ expect(reply).toContain('Blend is the one place I can read on Stellar.')
+ expect(reply).not.toContain('History tab')
+ })
+
+ it('says it could not read Blend when the read failed', () => {
+ const reply = blendPositionsReply(undefined)
+ expect(reply).toContain("I couldn't read Blend")
+ expect(reply).not.toMatch(/nothing/i)
+ })
+})
+
+describe('readPositionsReply', () => {
+ const answering = (body: unknown, status = 200) =>
+ vi.fn(async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch
+
+ it('asks for a wallet, and reads nothing without one', async () => {
+ const fetchImpl = answering({})
+ expect(await readPositionsReply('sui', 'Sui', undefined, fetchImpl)).toContain(
+ 'Connect your wallet'
+ )
+ expect(fetchImpl).not.toHaveBeenCalled()
+ })
+
+ it('reads a chain through its positions route, with the account escaped', async () => {
+ const fetchImpl = answering(report([venue('Aave', [position({ slot: 'supplied' })])]))
+ const reply = await readPositionsReply('avalanche', 'Avalanche', 'a b', fetchImpl)
+ expect(reply).toContain('withdraw all USDC from Aave')
+ expect(fetchImpl).toHaveBeenCalledWith(
+ '/api/chain/avalanche/positions?account=a%20b',
+ expect.objectContaining({ cache: 'no-store' })
+ )
+ })
+
+ it('reads Stellar through the Blend route', async () => {
+ const fetchImpl = answering({
+ position: null,
+ positions: blend({ supplied: [blendBalance('XLM', '1.0000000')] }),
+ })
+ expect(await readPositionsReply('stellar', 'Stellar', 'G', fetchImpl)).toContain(
+ '• 1 XLM on Blend'
+ )
+ expect(fetchImpl).toHaveBeenCalledWith(
+ '/api/lend/position?account=G',
+ expect.objectContaining({ cache: 'no-store' })
+ )
+ })
+
+ it.each([
+ ['a 503', answering({ error: 'x' }, 503)],
+ ['a malformed body', answering({ venues: [] })],
+ [
+ 'a dropped connection',
+ vi.fn(async () => {
+ throw new Error('network')
+ }) as unknown as typeof fetch,
+ ],
+ ])('never shows %s as nothing held', async (_, fetchImpl) => {
+ const chain = await readPositionsReply('sui', 'Sui', '0x', fetchImpl)
+ expect(chain).toContain("I couldn't read your positions")
+ const stellar = await readPositionsReply('stellar', 'Stellar', 'G', fetchImpl)
+ expect(stellar).toContain("I couldn't read Blend")
+ expect(stellar).not.toContain('You have nothing')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/positions-route.test.ts b/apps/dapp/lib/__tests__/positions-route.test.ts
new file mode 100644
index 00000000..2d0c9a14
--- /dev/null
+++ b/apps/dapp/lib/__tests__/positions-route.test.ts
@@ -0,0 +1,218 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ChainVenue, VenuePosition } from '../chains/types'
+
+const { limit, venues } = vi.hoisted(() => ({
+ limit: vi.fn(async (): Promise => undefined),
+ venues: { list: [] as unknown[] },
+}))
+
+vi.mock('../server/rate-limit', () => ({ enforceRateLimit: limit }))
+vi.mock('../chains/registry', () => ({
+ chainModule: (chain: string) =>
+ ['solana', 'sui', 'near', 'avalanche', 'monad'].includes(chain)
+ ? { chain, venues: venues.list }
+ : undefined,
+}))
+
+const EVM = `0x${'a'.repeat(40)}`
+const SUI = `0x${'a'.repeat(64)}`
+const SOLANA = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+
+const venue = (
+ name: string,
+ kinds: ChainVenue['kinds'],
+ positions?: ChainVenue['positions']
+): Pick => ({
+ id: name.toLowerCase(),
+ name,
+ kinds,
+ positions,
+})
+
+const held = (over: Partial = {}): VenuePosition => ({
+ slot: 'supplied',
+ symbol: 'USDC',
+ amount: '5',
+ ...over,
+})
+
+async function get(chain: string, account?: string): Promise {
+ const { GET } = await import('../../app/api/chain/[chain]/positions/route')
+ const query = account === undefined ? '' : `?account=${encodeURIComponent(account)}`
+ return GET(new Request(`http://localhost/api/chain/${chain}/positions${query}`), {
+ params: Promise.resolve({ chain }),
+ })
+}
+
+describe('the positions route', () => {
+ beforeEach(() => {
+ limit.mockClear()
+ venues.list = []
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('consults the limiter first, as a read', async () => {
+ limit.mockResolvedValueOnce(new Response('{}', { status: 429 }))
+ const res = await get('nowhere')
+ expect(res.status).toBe(429)
+ expect(limit).toHaveBeenCalledWith(expect.anything(), 'read')
+ }, 60_000)
+
+ it('is bounded to 20 seconds', async () => {
+ const route = await import('../../app/api/chain/[chain]/positions/route')
+ expect(route.maxDuration).toBe(20)
+ })
+
+ it('answers 404 for a chain it does not know', async () => {
+ expect((await get('nowhere', EVM)).status).toBe(404)
+ })
+
+ it.each([
+ ['solana', undefined],
+ ['solana', EVM],
+ ['solana', 'not an address'],
+ ['sui', EVM],
+ ['sui', SOLANA],
+ ['near', 'NOT.valid!'],
+ ['avalanche', SUI],
+ ['monad', SOLANA],
+ ['monad', `${EVM}a`],
+ ])('refuses on %s the account %j, and reads nothing', async (chain, account) => {
+ const read = vi.fn(async () => [held()])
+ venues.list = [venue('Aave', ['supply'], read)]
+ const res = await get(chain, account)
+ expect(res.status).toBe(400)
+ expect(await res.json()).toEqual({ error: 'invalid_account' })
+ expect(read).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['solana', SOLANA],
+ ['sui', SUI],
+ ['near', 'alice.near'],
+ ['avalanche', EVM],
+ ['monad', EVM],
+ ])('passes each venue the account on %s', async (chain, account) => {
+ const read = vi.fn(async () => [])
+ venues.list = [venue('Aave', ['supply'], read)]
+ expect((await get(chain, account)).status).toBe(200)
+ expect(read).toHaveBeenCalledWith(account)
+ })
+
+ it('returns what each venue holds, and says which could not be read', async () => {
+ venues.list = [
+ venue('Aave', ['supply', 'withdraw'], async () => [
+ held(),
+ held({ slot: 'borrowed', amount: '2' }),
+ ]),
+ venue('Morpho', ['supply', 'withdraw'], async () => {
+ throw new Error('rpc down')
+ }),
+ venue('Euler', ['supply', 'withdraw'], async () => []),
+ ]
+ const res = await get('avalanche', EVM)
+ expect(res.status).toBe(200)
+ expect(res.headers.get('cache-control')).toBe('no-store')
+ expect(await res.json()).toEqual({
+ venues: [
+ {
+ id: 'aave',
+ name: 'Aave',
+ kinds: ['supply', 'withdraw'],
+ positions: [held(), held({ slot: 'borrowed', amount: '2' })],
+ },
+ { id: 'euler', name: 'Euler', kinds: ['supply', 'withdraw'], positions: [] },
+ ],
+ unread: [{ id: 'morpho', name: 'Morpho' }],
+ })
+ })
+
+ it('counts a venue that can hold money but has no reader as unread, not empty', async () => {
+ venues.list = [venue('Kamino', ['supply', 'withdraw'])]
+ expect(await (await get('solana', SOLANA)).json()).toEqual({
+ venues: [],
+ unread: [{ id: 'kamino', name: 'Kamino' }],
+ })
+ })
+
+ it('leaves out a venue that only swaps or sends', async () => {
+ venues.list = [
+ venue('Jupiter', ['swap']),
+ venue('Wallet transfer', ['send']),
+ venue('Requests', ['invoice', 'pay_invoice']),
+ ]
+ expect(await (await get('solana', SOLANA)).json()).toEqual({ venues: [], unread: [] })
+ })
+
+ it('still reads a venue that has a reader, whatever its kinds', async () => {
+ venues.list = [venue('LST', ['swap'], async () => [held({ slot: 'staked' })])]
+ const body = (await (await get('solana', SOLANA)).json()) as {
+ venues: { positions: unknown[] }[]
+ }
+ expect(body.venues[0]?.positions).toEqual([held({ slot: 'staked' })])
+ })
+
+ it('drops zero figures', async () => {
+ venues.list = [
+ venue('Aave', ['supply'], async () => [
+ held({ amount: '0' }),
+ held({ amount: '0.0' }),
+ held({ amount: '0.000001' }),
+ ]),
+ ]
+ const body = (await (await get('avalanche', EVM)).json()) as {
+ venues: { positions: VenuePosition[] }[]
+ }
+ expect(body.venues[0]?.positions.map((p) => p.amount)).toEqual(['0.000001'])
+ })
+
+ it('reads a venue whose figure is no number as unread, not empty', async () => {
+ venues.list = [venue('Aave', ['supply'], async () => [held({ amount: 'NaN' })])]
+ expect(await (await get('avalanche', EVM)).json()).toEqual({
+ venues: [],
+ unread: [{ id: 'aave', name: 'Aave' }],
+ })
+ })
+
+ it('counts a venue that runs late as unread, and does not wait for it', async () => {
+ // Loaded before the clock stops: importing the route takes real time.
+ await import('../../app/api/chain/[chain]/positions/route')
+ vi.useFakeTimers()
+ venues.list = [
+ venue('Slow', ['supply'], () => new Promise(() => undefined)),
+ venue('Quick', ['supply'], async () => [held()]),
+ ]
+ const pending = get('avalanche', EVM)
+ await vi.advanceTimersByTimeAsync(6001)
+ const body = (await (await pending).json()) as {
+ venues: { name: string }[]
+ unread: { name: string }[]
+ }
+ expect(body.venues.map((v) => v.name)).toEqual(['Quick'])
+ expect(body.unread.map((v) => v.name)).toEqual(['Slow'])
+ })
+
+ it('reports all of them unread when none can be read, and still answers', async () => {
+ venues.list = [
+ venue('A1', ['stake'], async () => {
+ throw new Error('down')
+ }),
+ venue('B2', ['borrow'], () => {
+ throw new Error('throws before it even starts')
+ }),
+ ]
+ const res = await get('near', 'alice.near')
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({
+ venues: [],
+ unread: [
+ { id: 'a1', name: 'A1' },
+ { id: 'b2', name: 'B2' },
+ ],
+ })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/recipient-names.live.test.ts b/apps/dapp/lib/__tests__/recipient-names.live.test.ts
new file mode 100644
index 00000000..c1f0410a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/recipient-names.live.test.ts
@@ -0,0 +1,232 @@
+import { bcs } from '@mysten/sui/bcs'
+import { createPublicClient, http, parseAbi } from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import {
+ ANS_MONAD_REGISTRY,
+ ANS_MONAD_RESOLVER,
+ ANS_MONAD_ROOT,
+} from '../chains/names/alldomains-monad'
+import { ansAccounts } from '../chains/names/alldomains-solana'
+import { resolveName } from '../chains/names/resolve'
+import { snsAccounts, solAccounts } from '../chains/names/sns'
+import {
+ addressAt,
+ soleHolder,
+ solanaNameRpc,
+ TOKEN_2022_PROGRAM,
+} from '../chains/names/solana-rpc'
+import { SUINS_REGISTRY_TABLE } from '../chains/names/suins'
+import { NAME_TLDS, recipientName } from '../chains/names/tlds'
+import { suiClient } from '../chains/sui/client'
+import { ChainActionRefused, type ChainWithActions } from '../chains/types'
+
+/**
+ * Read-only: that the ids pinned for names still hold on chain, and that real names read as the
+ * resolver says. Signs nothing and sends nothing. Solana's public endpoint rate-limits hard, so
+ * these run one after another with a pause, and a name that changes hands fails its test, which
+ * says to look rather than that the code is wrong.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 90_000
+const SOLANA_URL = process.env['SOLANA_RPC_URL']?.trim() || 'https://api.mainnet-beta.solana.com'
+const MONAD_URL = 'https://rpc.monad.xyz'
+
+const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms))
+
+async function reads(chain: ChainWithActions, text: string): Promise {
+ await wait(1_500)
+ const typed = recipientName(text)
+ if (typed === undefined) throw new Error(`${text} is not a name`)
+ return (await resolveName(chain, typed)).address
+}
+
+async function refusal(chain: ChainWithActions, text: string): Promise {
+ try {
+ await reads(chain, text)
+ } catch (e) {
+ if (e instanceof ChainActionRefused) return e.message
+ throw e
+ }
+ throw new Error(`${text} read without a refusal`)
+}
+
+async function account(address: string): Promise<{ owner: string; executable: boolean } | null> {
+ await wait(1_500)
+ const reply = await fetch(SOLANA_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'getAccountInfo',
+ params: [address, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }],
+ }),
+ })
+ const body = (await reply.json()) as {
+ result: { value: { owner: string; executable: boolean } | null }
+ }
+ return body.result.value
+}
+
+describe.skipIf(SKIP).sequential('names on chain', () => {
+ it(
+ "SNS and .sol programs are still programs, and the root is the name program's",
+ async () => {
+ const programs = [
+ 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX',
+ 'nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk',
+ 'srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn',
+ 'ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK',
+ 'NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51',
+ ]
+ for (const program of programs) {
+ expect((await account(program))?.executable).toBe(true)
+ }
+ expect((await account('58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx'))?.owner).toBe(
+ 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX'
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a .sol name both registries agree on reads to that wallet, and .sns to the same',
+ async () => {
+ const { record } = await solAccounts('toly')
+ const { domain } = await snsAccounts('toly')
+ const [srs, sns] = await solanaNameRpc().accounts([record, domain])
+ expect(srs).not.toBeNull()
+ expect(sns).not.toBeNull()
+ const owner = addressAt(sns?.data as Uint8Array, 32)
+ expect(addressAt(srs?.data as Uint8Array, 34)).toBe(owner)
+ expect(await reads('solana', 'toly.sol')).toBe(owner)
+ expect(await reads('solana', 'toly.sns')).toBe(owner)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a .sol name the registries disagree on is refused, with both wallets named',
+ async () => {
+ const words = await refusal('solana', 'mert.sol')
+ expect(words).toContain('two different wallets')
+ expect(words).toContain('Nothing was signed or sent.')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'V2 and V1 records as they really are on chain',
+ async () => {
+ expect(await reads('solana', 'pumpfunian.sns')).toBe(
+ '25XoARksNA8wGUpTpqeczHc3yhkJgE2sCjHRvqqLi4Bb'
+ )
+ expect(await reads('solana', 'erizzo.sns')).toBe(
+ 'GRQfjSEYfYEjnWbU7S8fzSmjWcex5Gn6XyfxbXdRmUVm'
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a tokenized name reads to one holder, and a holder that is a program is refused',
+ async () => {
+ const { nft } = await snsAccounts('chiba')
+ expect((await account(nft))?.owner).toBe('nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk')
+ expect(await refusal('solana', 'chiba.sns')).toContain('held by a program')
+ // chiba is listed for sale, so Magic Eden's escrow holds it, as most sampled tokenized names were.
+ expect(await reads('solana', 'robansk2025.sns')).toBe(
+ 'BZxiMxyWkx9vLkidkMBGbph47vxEYxhe9we9g7mc93bH'
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'a tokenized .sol record has one Token-2022 holder at its canonical mint',
+ async () => {
+ await wait(1_500)
+ expect(
+ await soleHolder(
+ solanaNameRpc(),
+ '22egh7pqeQZ9gCm5wfB5g6EGBgRwqdzPDS2p1oy7BMte',
+ TOKEN_2022_PROGRAM
+ )
+ ).toBe('2q7yVFkqX1RfcEPBV94Wmw2QntXPVsAR8tqT5cw65npp')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'an unregistered name is refused',
+ async () => {
+ const words = await refusal('solana', 'no-such-name-here-20261105.sns')
+ expect(words).toContain("isn't registered")
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'AllDomains on Solana: the origin derives, and a known name reads',
+ async () => {
+ const { parent } = await ansAccounts('miester', 'poor')
+ expect(parent).toBe('8err4ThuTiZo9LbozHAvMrzXUmyPWj9urnMo38vC6FdQ')
+ expect(await reads('solana', 'miester.poor')).toBe(
+ '2EGGxj2qbNAJNgLCPKca8sxZYetyTjnoRspTPjzN2D67'
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'AllDomains on Monad: contracts hold code, the root names its registry and every ending',
+ async () => {
+ const client = createPublicClient({ transport: http(MONAD_URL, { timeout: 15_000 }) })
+ for (const address of [ANS_MONAD_ROOT, ANS_MONAD_REGISTRY, ANS_MONAD_RESOLVER]) {
+ expect(((await client.getCode({ address })) ?? '0x').length).toBeGreaterThan(2)
+ }
+ const root = parseAbi([
+ 'function registry() view returns (address)',
+ 'struct T { address a; address b; string tld; string d; string e; bool f; bytes32 g; bytes32 h; }',
+ 'function listTlds() view returns (T[])',
+ ])
+ expect(
+ await client.readContract({ address: ANS_MONAD_ROOT, abi: root, functionName: 'registry' })
+ ).toBe(ANS_MONAD_REGISTRY)
+ const tlds = await client.readContract({
+ address: ANS_MONAD_ROOT,
+ abi: root,
+ functionName: 'listTlds',
+ })
+ expect(tlds.map((t) => t.tld.slice(1)).sort()).toEqual([...NAME_TLDS.monad].sort())
+ expect(await reads('monad', 'miester.mon')).toBe('0x94Bfb92da83B27B39370550CA038Af96d182462f')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'SuiNS: the registry table is the one the SuiNS object holds, and names read',
+ async () => {
+ const V1 = '0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0'
+ const SUINS = '0x6e0ddefc0ad98889c04bab9639e512c21766c5e6366f89e696956d9be6952871'
+ const { dynamicField } = await suiClient().core.getDynamicField({
+ parentId: SUINS,
+ name: {
+ type: `${V1}::suins::RegistryKey<${V1}::registry::Registry>`,
+ bcs: bcs.bool().serialize(false).toBytes(),
+ },
+ })
+ expect(dynamicField.value.type).toBe(`${V1}::registry::Registry`)
+ const table = `0x${Buffer.from(dynamicField.value.bcs.subarray(0, 32)).toString('hex')}`
+ expect(table).toBe(SUINS_REGISTRY_TABLE)
+ expect(await reads('sui', 'adeniyi.sui')).toBe(
+ '0x1eb7c57e3f2bd0fc6cb9dcffd143ea957e4d98f805c358733f76dee0667fe0b1'
+ )
+ expect(await refusal('sui', 'ada.sui')).toContain('no address set')
+ expect(await refusal('sui', 'no-such-name-here-20261105.sui')).toContain("isn't registered")
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/recipient-names.test.ts b/apps/dapp/lib/__tests__/recipient-names.test.ts
new file mode 100644
index 00000000..7dc317de
--- /dev/null
+++ b/apps/dapp/lib/__tests__/recipient-names.test.ts
@@ -0,0 +1,1213 @@
+import { generateKeyPair, getAddressFromPublicKey, signBytes, type Address } from '@solana/kit'
+import { getAddress, keccak256, toBytes, zeroAddress } from 'viem'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { ansAccounts, resolveAllDomainsSolana } from '../chains/names/alldomains-solana'
+import {
+ ANS_MONAD_REGISTRY,
+ ANS_MONAD_RESOLVER,
+ ANS_MONAD_ROOT,
+ ansNode,
+ resolveAllDomainsMonad,
+ type MonadNameReader,
+} from '../chains/names/alldomains-monad'
+import { resolveName, resolveRecipient, withResolvedName } from '../chains/names/resolve'
+import { snsAccounts, resolveSnsName, solAccounts, resolveSolName } from '../chains/names/sns'
+import type { NameAccount, SolanaNameRpc } from '../chains/names/solana-rpc'
+import {
+ NameRecord,
+ SUINS_NAME_RECORD_TYPE,
+ SUINS_REGISTRY_TABLE,
+ resolveSuiName,
+ type SuiNameReader,
+} from '../chains/names/suins'
+import { recipientName } from '../chains/names/tlds'
+import { parseChainAction } from '../chains/parse-action'
+import { prepareChainAction } from '../chains/prepare'
+import { isValidRecipient, sendRefusal } from '../chains/recipient'
+import { createSendVenue } from '../chains/send'
+import { SOLANA_TOKENS } from '../chains/solana/tokens'
+import {
+ ChainActionRefused,
+ type ChainActionKind,
+ type ChainActionRequest,
+ type ChainModule,
+ type ChainVenue,
+ type ChainWithActions,
+ type PreparedAction,
+} from '../chains/types'
+
+vi.hoisted(() => {
+ process.env['AUTH_SECRET'] = 'x'.repeat(48)
+})
+
+const NOTHING = 'Nothing was signed or sent.'
+const NAME_PROGRAM = 'namesLPneVptA9Z5rqUDD9tMTWEJwofgaYwp8cawRkX'
+const SNS_ROOT = '58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx'
+const TOKENIZER = 'nftD3vbNkNqfj2Sd3HZwbpw4BxxKWr4AjGb9X38JeZk'
+const RECORDS_CENTRAL_STATE = '2pMnqHvei2N5oDcVGCRdZx48gqti199wr5CsyTTafsbo'
+const SRS_PROGRAM = 'srsWjm76StJucL7atFyPSdXFaVLNPFqEt1uFEDPrZsn'
+const SRS_CLASS = 'Dqc9TrYg1AZLEa6UkZUqCDrr9zsHQveo1szGTE1BTW5'
+const ANS_PROGRAM = 'ALTNSZ46uaAUU7XUV6awvdorLGqAsPwa9shm7h4uP2FK'
+const NAME_HOUSE = 'NH3uX6FtVE2fNREAioP7hm5RaozotZxeL6khU1EHx51'
+const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
+const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
+const ZERO = '11111111111111111111111111111111'
+
+const { getAddressEncoder } = await import('@solana/kit')
+const encode = (address: string): Uint8Array =>
+ new Uint8Array(getAddressEncoder().encode(address as Address))
+
+function bytes(...parts: (Uint8Array | number[])[]): Uint8Array {
+ return new Uint8Array(parts.flatMap((part) => [...part]))
+}
+
+function u64(value: number | bigint): Uint8Array {
+ const out = new Uint8Array(8)
+ new DataView(out.buffer).setBigUint64(0, BigInt(value), true)
+ return out
+}
+
+function i64(value: number | bigint): Uint8Array {
+ const out = new Uint8Array(8)
+ new DataView(out.buffer).setBigInt64(0, BigInt(value), true)
+ return out
+}
+
+function registry(parent: string, owner: string, cls = ZERO, extra = 0): Uint8Array {
+ return bytes(encode(parent), encode(owner), encode(cls), new Uint8Array(extra))
+}
+
+function v1Data(domain: string, owner: string, target: string, signature: Uint8Array): Uint8Array {
+ return bytes(registry(domain, owner), encode(target), signature)
+}
+
+function hexMessage(target: string, record: string): Uint8Array {
+ const raw = bytes(encode(target), encode(record))
+ const hex = [...raw].map((b) => b.toString(16).padStart(2, '0')).join('')
+ return new TextEncoder().encode(hex)
+}
+
+function v2Data(
+ domain: string,
+ stale: string,
+ roa: string,
+ content: Uint8Array,
+ { staleness = 1, right = 1 }: { staleness?: number; right?: number } = {}
+): Uint8Array {
+ const header = new Uint8Array(8)
+ const view = new DataView(header.buffer)
+ view.setUint16(0, staleness, true)
+ view.setUint16(2, right, true)
+ view.setUint32(4, content.length, true)
+ return bytes(
+ registry(domain, RECORDS_CENTRAL_STATE, RECORDS_CENTRAL_STATE),
+ header,
+ encode(stale),
+ encode(roa),
+ content
+ )
+}
+
+function nftRecord(tag: number, nameAccount: string, mint: string): Uint8Array {
+ return bytes([tag, 255], encode(nameAccount), encode(ZERO), encode(mint))
+}
+
+function mintData({ supply = 1, decimals = 0, init = 1 } = {}): Uint8Array {
+ return bytes(new Uint8Array(36), u64(supply), [decimals, init], new Uint8Array(36))
+}
+
+function tokenData(mint: string, owner: string, amount = 1, state = 1): Uint8Array {
+ return bytes(
+ encode(mint),
+ encode(owner),
+ u64(amount),
+ new Uint8Array(36),
+ [state],
+ new Uint8Array(56)
+ )
+}
+
+function srsData(
+ cls: string,
+ ownerType: number,
+ owner: string,
+ {
+ frozen = 0,
+ expiry = 0,
+ discriminator = 2,
+ }: { frozen?: number; expiry?: number; discriminator?: number } = {}
+): Uint8Array {
+ return bytes(
+ [discriminator],
+ encode(cls),
+ [ownerType],
+ encode(owner),
+ [frozen],
+ i64(expiry),
+ new Uint8Array(40)
+ )
+}
+
+class World implements SolanaNameRpc {
+ accountsByAddress = new Map()
+ holders = new Map()
+ reads = 0
+ broken = false
+
+ set(address: string, program: string, data: Uint8Array): void {
+ this.accountsByAddress.set(address, { program, data })
+ }
+
+ async accounts(addresses: readonly string[]): Promise<(NameAccount | null)[]> {
+ this.reads += 1
+ if (this.broken) throw new Error('rpc down')
+ return addresses.map((address) => this.accountsByAddress.get(address) ?? null)
+ }
+
+ async tokenAccounts(mint: string): Promise<{ address: string; data: Uint8Array }[]> {
+ this.reads += 1
+ if (this.broken) throw new Error('rpc down')
+ return this.holders.get(mint) ?? []
+ }
+}
+
+let owner: { address: string; keys: CryptoKeyPair }
+let oldOwner: { address: string; keys: CryptoKeyPair }
+let wallet: string
+let holder: string
+let me: string
+
+async function keyed(): Promise<{ address: string; keys: CryptoKeyPair }> {
+ const keys = await generateKeyPair()
+ return { address: await getAddressFromPublicKey(keys.publicKey), keys }
+}
+
+beforeAll(async () => {
+ owner = await keyed()
+ oldOwner = await keyed()
+ wallet = (await keyed()).address
+ holder = (await keyed()).address
+ me = (await keyed()).address
+})
+
+async function snsWorld(label = 'ada', domainOwner = owner.address): Promise {
+ const world = new World()
+ const { domain } = await snsAccounts(label)
+ world.set(domain, NAME_PROGRAM, registry(SNS_ROOT, domainOwner))
+ return world
+}
+
+async function setHolder(
+ world: World,
+ label = 'ada',
+ { tag = 2, supply = 1, holders = 1 }: { tag?: number; supply?: number; holders?: number } = {}
+): Promise {
+ const { domain, nft, mint } = await snsAccounts(label)
+ world.set(domain, NAME_PROGRAM, registry(SNS_ROOT, nft))
+ world.set(nft, TOKENIZER, nftRecord(tag, domain, mint))
+ world.set(mint, TOKEN_PROGRAM, mintData({ supply }))
+ world.holders.set(
+ mint,
+ Array.from({ length: holders }, (_, i) => ({
+ address: `token${i}`,
+ data: tokenData(mint, holder),
+ }))
+ )
+}
+
+async function setV2(
+ world: World,
+ target: string,
+ stale = owner.address,
+ roa = target
+): Promise {
+ const { domain, v2 } = await snsAccounts('ada')
+ world.set(v2, NAME_PROGRAM, v2Data(domain, stale, roa, encode(target)))
+}
+
+async function setV1(world: World, target: string, signer = owner.keys): Promise {
+ const { domain, v1 } = await snsAccounts('ada')
+ const signature = await signBytes(signer.privateKey, hexMessage(target, v1))
+ world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, target, new Uint8Array(signature)))
+}
+
+describe('what counts as a name', () => {
+ it('reads the shapes a person types, through every kind that names someone', () => {
+ expect(parseChainAction('send 50 USDC to ada.sol')).toMatchObject({
+ kind: 'send',
+ recipient: 'ada.sol',
+ })
+ expect(parseChainAction('send 50 USDC to ada.sol.')).toMatchObject({ recipient: 'ada.sol' })
+ expect(parseChainAction('stream 100 USDC to bob.mon over 30 days')).toMatchObject({
+ kind: 'stream',
+ recipient: 'bob.mon',
+ })
+ expect(parseChainAction('invoice ada.skr for 100 USDC due 2027-01-01')).toMatchObject({
+ kind: 'invoice',
+ recipient: 'ada.skr',
+ })
+ })
+
+ it('lower-cases a name and leaves anything else alone', () => {
+ expect(recipientName('ADA.Sol')).toMatchObject({ name: 'ada.sol', label: 'ada', tld: 'sol' })
+ expect(recipientName('pay.ada.sol')).toMatchObject({ tld: 'sol', sub: true })
+ expect(recipientName('alice.near')).toBeUndefined()
+ expect(recipientName(SNS_ROOT)).toBeUndefined()
+ expect(recipientName(zeroAddress)).toBeUndefined()
+ expect(recipientName('ada.eth')).toBeUndefined()
+ })
+
+ it('never lets a name stand as an address, even where it fits the shape', () => {
+ expect(isValidRecipient('near', 'ada.sol')).toBe(false)
+ expect(isValidRecipient('near', 'ada.sui')).toBe(false)
+ expect(isValidRecipient('near', 'alice.near')).toBe(true)
+ })
+})
+
+describe('a name on the wrong chain', () => {
+ const ON_SUI = `ada.sol is a Solana name, and this is on Sui, so it can't be read here. Switch to Solana, or use a Sui address. ${NOTHING}`
+
+ it('says so in plain words', () => {
+ expect(sendRefusal('sui', 'USDC', 'ada.sol')).toBe(ON_SUI)
+ expect(sendRefusal('near', 'USDC', 'ada.sol')).toContain('ada.sol is a Solana name')
+ expect(sendRefusal('avalanche', 'USDC', 'ada.sol')).toContain('this is on Avalanche')
+ expect(sendRefusal('monad', 'USDC', 'ada.sns')).toContain('this is on Monad')
+ expect(sendRefusal('solana', 'USDC', 'bob.mon')).toContain('bob.mon is a Monad name')
+ expect(sendRefusal('solana', 'USDC', 'bob.sui')).toContain('bob.sui is a Sui name')
+ expect(sendRefusal('monad', 'USDC', 'bob.skr')).toContain('bob.skr is a Solana name')
+ })
+
+ it('refuses subnames and characters it will not read, on the right chain', () => {
+ expect(sendRefusal('solana', 'USDC', 'pay.ada.sol')).toContain('pay.ada.sol')
+ expect(sendRefusal('solana', 'USDC', 'pay.ada.sol')).toContain(NOTHING)
+ expect(sendRefusal('solana', 'USDC', 'a_b.sol')).toContain("can't be read as a name")
+ })
+
+ it('passes a name on its own chain, and still checks the token', () => {
+ expect(sendRefusal('solana', 'USDC', 'ada.sol')).toBeUndefined()
+ expect(sendRefusal('solana', 'USDC', 'ada.poor')).toBeUndefined()
+ expect(sendRefusal('sui', 'USDC', 'ada.sui')).toBeUndefined()
+ expect(sendRefusal('monad', 'USDC', 'ada.mon')).toBeUndefined()
+ expect(sendRefusal('solana', 'DOGE', 'ada.sol')).toContain('Sending DOGE')
+ })
+
+ it('refuses in the same words through the money path, with no read at all', async () => {
+ const fetchSpy = vi.fn()
+ vi.stubGlobal('fetch', fetchSpy)
+ await expect(
+ prepareChainAction(
+ moduleWith('sui', [sendVenueStub('send')]),
+ { kind: 'send', amount: '1', tokenIn: 'USDC', recipient: 'ada.sol' },
+ '0x' + '1'.repeat(64)
+ )
+ ).rejects.toThrow(ON_SUI)
+ expect(fetchSpy).not.toHaveBeenCalled()
+ vi.unstubAllGlobals()
+ })
+})
+
+describe('SNS: the order, on ada.sns', () => {
+ it('step 4: pays the owner when nothing else is set', async () => {
+ const world = await snsWorld()
+ await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(owner.address)
+ })
+
+ it('step 3: a signed V1 record beats the owner', async () => {
+ const world = await snsWorld()
+ await setV1(world, wallet)
+ await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(wallet)
+ })
+
+ it('step 2: a verified V2 record beats V1 and the owner', async () => {
+ const world = await snsWorld()
+ await setV1(world, holder)
+ await setV2(world, wallet)
+ await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(wallet)
+ })
+
+ it('step 1: the token holder beats every record', async () => {
+ const world = await snsWorld()
+ await setV2(world, wallet)
+ await setHolder(world)
+ await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(holder)
+ })
+
+ it('an inactive token record is passed over', async () => {
+ const world = await snsWorld()
+ const { domain, nft, mint } = await snsAccounts('ada')
+ world.set(nft, TOKENIZER, nftRecord(3, domain, mint))
+ await expect(resolveSnsName(world, 'ada.sns', 'ada')).resolves.toBe(owner.address)
+ })
+})
+
+describe('SNS: refusals', () => {
+ const refused = async (world: SolanaNameRpc, match: RegExp | string): Promise => {
+ const attempt = resolveSnsName(world, 'ada.sns', 'ada')
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(match)
+ await expect(attempt).rejects.toThrow(NOTHING)
+ }
+
+ it('an unregistered name', async () => {
+ await refused(new World(), "isn't registered")
+ })
+
+ it("a domain account not under the SNS root, or not the program's", async () => {
+ const world = new World()
+ const { domain } = await snsAccounts('ada')
+ world.set(domain, NAME_PROGRAM, registry(wallet, owner.address))
+ await refused(world, 'could not be confirmed')
+ world.set(domain, wallet, registry(SNS_ROOT, owner.address))
+ await refused(world, 'could not be confirmed')
+ })
+
+ it('a V1 record signed by nobody', async () => {
+ const world = await snsWorld()
+ const { domain, v1 } = await snsAccounts('ada')
+ world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, wallet, new Uint8Array(64)))
+ await refused(world, 'not signed')
+ })
+
+ it('a V1 record signed by a previous owner', async () => {
+ const world = await snsWorld()
+ await setV1(world, wallet, oldOwner.keys)
+ await refused(world, 'not signed')
+ })
+
+ it('a V1 record whose address was changed after signing', async () => {
+ const world = await snsWorld()
+ await setV1(world, wallet)
+ const { domain, v1 } = await snsAccounts('ada')
+ const signed = world.accountsByAddress.get(v1) as NameAccount
+ world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, holder, signed.data.slice(128)))
+ await refused(world, 'not signed')
+ })
+
+ it('a V1 record signed over the raw bytes, not the hex SNS signs', async () => {
+ const world = await snsWorld()
+ const { domain, v1 } = await snsAccounts('ada')
+ const signature = await signBytes(owner.keys.privateKey, bytes(encode(wallet), encode(v1)))
+ world.set(v1, NAME_PROGRAM, v1Data(domain, owner.address, wallet, new Uint8Array(signature)))
+ await refused(world, 'not signed')
+ })
+
+ it('a V1 record cut short', async () => {
+ const world = await snsWorld()
+ const { domain, v1 } = await snsAccounts('ada')
+ world.set(v1, NAME_PROGRAM, bytes(registry(domain, owner.address), encode(wallet)))
+ await refused(world, 'not signed')
+ })
+
+ it('a stale V2 record, set by a previous owner, even with a good V1 under it', async () => {
+ const world = await snsWorld()
+ await setV1(world, holder)
+ await setV2(world, wallet, oldOwner.address)
+ await refused(world, 'previous owner')
+ })
+
+ it('a V2 record whose right of association does not match its content', async () => {
+ const world = await snsWorld()
+ await setV2(world, wallet, owner.address, holder)
+ await refused(world, "doesn't prove")
+ })
+
+ it('a V2 record with validation other than Solana', async () => {
+ const world = await snsWorld()
+ const { domain, v2 } = await snsAccounts('ada')
+ world.set(v2, NAME_PROGRAM, v2Data(domain, owner.address, wallet, encode(wallet), { right: 3 }))
+ await refused(world, "doesn't prove")
+ })
+
+ it('a V2 record whose content is not one address', async () => {
+ const world = await snsWorld()
+ const { domain, v2 } = await snsAccounts('ada')
+ world.set(v2, NAME_PROGRAM, v2Data(domain, owner.address, wallet, new Uint8Array(20)))
+ await refused(world, "doesn't prove")
+ })
+
+ it('a record account the name program does not own', async () => {
+ const world = await snsWorld()
+ const { domain, v2 } = await snsAccounts('ada')
+ world.set(v2, wallet, v2Data(domain, owner.address, wallet, encode(wallet)))
+ await refused(world, "doesn't prove")
+ })
+
+ it('a token record with no sole holder', async () => {
+ let world = await snsWorld()
+ await setHolder(world, 'ada', { holders: 0 })
+ await refused(world, 'single holder')
+ world = await snsWorld()
+ await setHolder(world, 'ada', { holders: 2 })
+ await refused(world, 'single holder')
+ world = await snsWorld()
+ await setHolder(world, 'ada', { supply: 2 })
+ await refused(world, 'single holder')
+ })
+
+ it('a token record pointing at another mint', async () => {
+ const world = await snsWorld()
+ await setHolder(world)
+ const { domain, nft } = await snsAccounts('ada')
+ world.set(nft, TOKENIZER, nftRecord(2, domain, wallet))
+ await refused(world, 'single holder')
+ })
+
+ it('a token record in a state it does not know', async () => {
+ const world = await snsWorld()
+ const { domain, nft, mint } = await snsAccounts('ada')
+ world.set(nft, TOKENIZER, nftRecord(1, domain, mint))
+ await refused(world, 'could not be confirmed')
+ })
+})
+
+describe('.sol: the .sol registry and SNS must agree', () => {
+ async function solWorld(srsOwner = owner.address, options = {}): Promise {
+ const world = await snsWorld()
+ const { record } = await solAccounts('ada')
+ world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 0, srsOwner, options))
+ return world
+ }
+
+ it('pays when both say the same wallet', async () => {
+ await expect(resolveSolName(await solWorld(), 'ada.sol', 'ada')).resolves.toBe(owner.address)
+ })
+
+ it('refuses when they name different wallets', async () => {
+ const attempt = resolveSolName(await solWorld(wallet), 'ada.sol', 'ada')
+ await expect(attempt).rejects.toThrow('two different wallets')
+ await expect(attempt).rejects.toThrow(wallet)
+ await expect(attempt).rejects.toThrow(owner.address)
+ })
+
+ it('refuses a V2 record that sends elsewhere than the .sol owner', async () => {
+ const world = await solWorld()
+ await setV2(world, wallet)
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('two different wallets')
+ })
+
+ it('refuses a name only one of them knows', async () => {
+ const onlySns = await snsWorld()
+ await expect(resolveSolName(onlySns, 'ada.sol', 'ada')).rejects.toThrow(ChainActionRefused)
+ const onlySrs = new World()
+ const { record } = await solAccounts('ada')
+ onlySrs.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 0, owner.address))
+ await expect(resolveSolName(onlySrs, 'ada.sol', 'ada')).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses an expired record, and passes the no-expiry and future ones', async () => {
+ const now = Math.floor(Date.now() / 1000)
+ await expect(
+ resolveSolName(await solWorld(owner.address, { expiry: now - 1 }), 'ada.sol', 'ada')
+ ).rejects.toThrow('expired')
+ await expect(
+ resolveSolName(await solWorld(owner.address, { expiry: now + 86_400 }), 'ada.sol', 'ada')
+ ).resolves.toBe(owner.address)
+ })
+
+ it('refuses a frozen, misclassed, wrongly owned or malformed record', async () => {
+ await expect(
+ resolveSolName(await solWorld(owner.address, { frozen: 1 }), 'ada.sol', 'ada')
+ ).rejects.toThrow('could not be confirmed')
+ await expect(
+ resolveSolName(await solWorld(owner.address, { discriminator: 1 }), 'ada.sol', 'ada')
+ ).rejects.toThrow('could not be confirmed')
+ const world = await snsWorld()
+ const { record } = await solAccounts('ada')
+ world.set(record, SRS_PROGRAM, srsData(wallet, 0, owner.address))
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('could not be confirmed')
+ world.set(record, wallet, srsData(SRS_CLASS, 0, owner.address))
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('could not be confirmed')
+ world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 2, owner.address))
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('could not be confirmed')
+ })
+
+ it('reads a tokenized .sol record through its canonical Token-2022 mint', async () => {
+ const world = await snsWorld('ada', holder)
+ const { record, mint } = await solAccounts('ada')
+ world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 1, mint))
+ world.set(mint, TOKEN_2022, mintData())
+ world.holders.set(mint, [{ address: 't', data: tokenData(mint, holder) }])
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).resolves.toBe(holder)
+
+ world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 1, wallet))
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('single holder')
+ })
+
+ it('refuses a mint run by the wrong token program', async () => {
+ const world = await snsWorld('ada', holder)
+ const { record, mint } = await solAccounts('ada')
+ world.set(record, SRS_PROGRAM, srsData(SRS_CLASS, 1, mint))
+ world.set(mint, TOKEN_PROGRAM, mintData())
+ world.holders.set(mint, [{ address: 't', data: tokenData(mint, holder) }])
+ await expect(resolveSolName(world, 'ada.sol', 'ada')).rejects.toThrow('single holder')
+ })
+})
+
+describe('AllDomains on Solana', () => {
+ const ALT_DISCRIMINATOR = [0x44, 0x48, 0x58, 0x2c, 0x0f, 0xa7, 0x67, 0xf3]
+ const NFT_DISCRIMINATOR = [174, 190, 114, 100, 177, 14, 90, 254]
+
+ function ansRecord(
+ parent: string,
+ recordOwner: string,
+ expiry = 0,
+ discriminator = ALT_DISCRIMINATOR
+ ): Uint8Array {
+ return bytes(
+ discriminator,
+ encode(parent),
+ encode(recordOwner),
+ encode(ZERO),
+ u64(expiry),
+ u64(0),
+ new Uint8Array(80)
+ )
+ }
+
+ it('pays the owner of an unwrapped name', async () => {
+ const world = new World()
+ const { parent, name } = await ansAccounts('ada', 'poor')
+ world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address))
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).resolves.toBe(
+ owner.address
+ )
+ })
+
+ it('pays the holder of a wrapped name', async () => {
+ const world = new World()
+ const { parent, name, nftRecord: nft } = await ansAccounts('ada', 'poor')
+ world.set(name, ANS_PROGRAM, ansRecord(parent, nft))
+ world.set(
+ nft,
+ NAME_HOUSE,
+ bytes(NFT_DISCRIMINATOR, [1, 255], encode(name), encode(ZERO), encode(wallet), encode(ZERO))
+ )
+ world.set(wallet, TOKEN_PROGRAM, mintData())
+ world.holders.set(wallet, [{ address: 't', data: tokenData(wallet, holder) }])
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).resolves.toBe(holder)
+ })
+
+ it('refuses a wrapped name whose record is inactive or for another name', async () => {
+ const world = new World()
+ const { parent, name, nftRecord: nft } = await ansAccounts('ada', 'poor')
+ world.set(name, ANS_PROGRAM, ansRecord(parent, nft))
+ world.set(
+ nft,
+ NAME_HOUSE,
+ bytes(NFT_DISCRIMINATOR, [2, 255], encode(name), encode(ZERO), encode(wallet), encode(ZERO))
+ )
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ 'could not be confirmed'
+ )
+ world.set(
+ nft,
+ NAME_HOUSE,
+ bytes(NFT_DISCRIMINATOR, [1, 255], encode(wallet), encode(ZERO), encode(wallet), encode(ZERO))
+ )
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ 'could not be confirmed'
+ )
+ })
+
+ it('refuses an expired name, and passes one with time left', async () => {
+ const now = Math.floor(Date.now() / 1000)
+ const world = new World()
+ const { parent, name } = await ansAccounts('ada', 'poor')
+ world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address, now - 1))
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ 'expired'
+ )
+ world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address, now + 86_400))
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).resolves.toBe(
+ owner.address
+ )
+ })
+
+ it('refuses a missing name, a wrong parent, a wrong program, a wrong discriminator', async () => {
+ const world = new World()
+ const { parent, name } = await ansAccounts('ada', 'poor')
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ "isn't registered"
+ )
+ world.set(name, ANS_PROGRAM, ansRecord(wallet, owner.address))
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ 'could not be confirmed'
+ )
+ world.set(name, wallet, ansRecord(parent, owner.address))
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ 'could not be confirmed'
+ )
+ world.set(name, ANS_PROGRAM, ansRecord(parent, owner.address, 0, [1, 2, 3, 4, 5, 6, 7, 8]))
+ await expect(resolveAllDomainsSolana(world, 'ada.poor', 'ada', 'poor')).rejects.toThrow(
+ 'could not be confirmed'
+ )
+ })
+})
+
+describe('AllDomains on Monad', () => {
+ const HOLDER = getAddress('0x94bfb92da83b27b39370550ca038af96d182462f')
+ const OTHER = getAddress('0x1111111111111111111111111111111111111111')
+ const REGISTRAR = getAddress('0xbc811d4c4a17a645e024e8b9eefa72d4ed30cdd0')
+
+ interface Chain {
+ tld?: string
+ registrar?: string
+ owner?: string
+ resolver?: string
+ name?: string
+ expiry?: bigint
+ nftOwner?: string
+ addr?: string
+ fail?: boolean
+ }
+
+ function reader(chain: Chain = {}): MonadNameReader & { calls: string[] } {
+ const calls: string[] = []
+ return {
+ calls,
+ read: async ({ address, functionName, args }) => {
+ calls.push(`${address}:${functionName}`)
+ if (chain.fail === true) throw new Error('rpc down')
+ const node = ansNode('ada', 'mon')
+ switch (functionName) {
+ case 'getTld':
+ expect(address).toBe(ANS_MONAD_ROOT)
+ expect(args?.[0]).toBe(keccak256(toBytes('.mon')))
+ return {
+ tld: chain.tld ?? '.mon',
+ registrar: chain.registrar ?? REGISTRAR,
+ }
+ case 'owner':
+ expect(address).toBe(ANS_MONAD_REGISTRY)
+ expect(args?.[0]).toBe(node)
+ return chain.owner ?? HOLDER
+ case 'resolver':
+ expect(address).toBe(ANS_MONAD_REGISTRY)
+ return chain.resolver ?? ANS_MONAD_RESOLVER
+ case 'nameData':
+ expect(address).toBe(chain.registrar ?? REGISTRAR)
+ expect(args?.[0]).toBe(BigInt(keccak256(toBytes('ada'))))
+ return {
+ name: chain.name ?? 'ada',
+ expiry: chain.expiry ?? BigInt(Math.floor(Date.now() / 1000) + 86_400),
+ owner: chain.nftOwner ?? HOLDER,
+ }
+ case 'addr':
+ expect(address).toBe(ANS_MONAD_RESOLVER)
+ return chain.addr ?? zeroAddress
+ default:
+ throw new Error(`unexpected ${functionName}`)
+ }
+ },
+ }
+ }
+
+ const resolve = (chain?: Chain): Promise =>
+ resolveAllDomainsMonad(reader(chain), 'ada.mon', 'ada', 'mon')
+
+ it('computes the node as the protocol does, with the dot in the ending', () => {
+ // The .mon node as root.listTlds() returns it on chain, then the label over it.
+ const tldNode = '0xcbc339c8da5e6733357b69f6b396a8319afd0a9624eb6cc5ef65e35b54a77421'
+ expect(ansNode('miester', 'mon')).toBe(
+ keccak256(`0x${tldNode.slice(2)}${keccak256(toBytes('miester')).slice(2)}`)
+ )
+ })
+
+ it('pays the owner when the registry, the registrar and the resolver agree', async () => {
+ await expect(resolve()).resolves.toBe(HOLDER)
+ await expect(resolve({ addr: HOLDER })).resolves.toBe(HOLDER)
+ await expect(resolve({ resolver: zeroAddress })).resolves.toBe(HOLDER)
+ })
+
+ it('refuses an unregistered name', async () => {
+ await expect(resolve({ owner: zeroAddress, nftOwner: zeroAddress, name: '' })).rejects.toThrow(
+ "isn't registered"
+ )
+ })
+
+ it('refuses an expired name', async () => {
+ await expect(resolve({ expiry: BigInt(Math.floor(Date.now() / 1000) - 1) })).rejects.toThrow(
+ 'expired'
+ )
+ })
+
+ it('refuses when the registrar and the registry disagree', async () => {
+ await expect(resolve({ nftOwner: OTHER })).rejects.toThrow('two different wallets')
+ })
+
+ it('refuses an address record set elsewhere than the owner', async () => {
+ await expect(resolve({ addr: OTHER })).rejects.toThrow('two different wallets')
+ })
+
+ it('refuses a resolver this app does not know', async () => {
+ await expect(resolve({ resolver: OTHER })).rejects.toThrow('could not be confirmed')
+ })
+
+ it('refuses a registrar record for another name or ending', async () => {
+ await expect(resolve({ name: 'bob' })).rejects.toThrow('could not be confirmed')
+ await expect(resolve({ tld: '.nad' })).rejects.toThrow('could not be confirmed')
+ await expect(resolve({ registrar: zeroAddress })).rejects.toThrow('could not be confirmed')
+ })
+
+ it('never reads a registrar this root did not name, and a failed read is a refusal', async () => {
+ const seen = reader()
+ await resolveAllDomainsMonad(seen, 'ada.mon', 'ada', 'mon')
+ expect(seen.calls[0]).toBe(`${ANS_MONAD_ROOT}:getTld`)
+ await expect(
+ resolveName('monad', recipientName('ada.mon')!, { monad: () => reader({ fail: true }) })
+ ).rejects.toThrow('could not be read')
+ })
+})
+
+describe('SuiNS', () => {
+ const TARGET = '0x' + 'a'.repeat(64)
+ const future = (): bigint => BigInt(Date.now() + 86_400_000)
+ const encodeNameRecord = ({
+ expiresMs,
+ target,
+ }: {
+ expiresMs: bigint
+ target: string | null
+ }): Uint8Array =>
+ NameRecord.serialize({
+ nft_id: '0x' + 'b'.repeat(64),
+ expiration_timestamp_ms: expiresMs,
+ target_address: target,
+ data: { contents: [{ key: 'avatar', value: 'x' }] },
+ }).toBytes()
+
+ function reader(
+ record: { value: Uint8Array; type: string } | null | 'broken'
+ ): SuiNameReader & { labels: string[][] } {
+ const labels: string[][] = []
+ return {
+ labels,
+ nameRecord: async (parts) => {
+ labels.push([...parts])
+ if (record === 'broken') throw new Error('rpc down')
+ return record
+ },
+ }
+ }
+
+ it('pays the target, reading the labels in the order the registry keys them', async () => {
+ const seen = reader({
+ value: encodeNameRecord({ expiresMs: future(), target: TARGET }),
+ type: SUINS_NAME_RECORD_TYPE,
+ })
+ await expect(resolveSuiName(seen, 'ada.sui', 'ada')).resolves.toBe(TARGET)
+ expect(seen.labels).toEqual([['sui', 'ada']])
+ expect(SUINS_REGISTRY_TABLE).toMatch(/^0x[0-9a-f]{64}$/)
+ })
+
+ it('refuses a name with no target set', async () => {
+ await expect(
+ resolveSuiName(
+ reader({
+ value: encodeNameRecord({ expiresMs: future(), target: null }),
+ type: SUINS_NAME_RECORD_TYPE,
+ }),
+ 'ada.sui',
+ 'ada'
+ )
+ ).rejects.toThrow('no address set')
+ })
+
+ it('refuses an expired name', async () => {
+ await expect(
+ resolveSuiName(
+ reader({
+ value: encodeNameRecord({ expiresMs: BigInt(Date.now() - 1), target: TARGET }),
+ type: SUINS_NAME_RECORD_TYPE,
+ }),
+ 'ada.sui',
+ 'ada'
+ )
+ ).rejects.toThrow('expired')
+ })
+
+ it('refuses an unregistered name', async () => {
+ await expect(resolveSuiName(reader(null), 'ada.sui', 'ada')).rejects.toThrow("isn't registered")
+ })
+
+ it('refuses a value of any other type, and bytes that do not decode', async () => {
+ await expect(
+ resolveSuiName(
+ reader({
+ value: encodeNameRecord({ expiresMs: future(), target: TARGET }),
+ type: '0x2::other::Thing',
+ }),
+ 'ada.sui',
+ 'ada'
+ )
+ ).rejects.toThrow('could not be confirmed')
+ await expect(
+ resolveName('sui', recipientName('ada.sui')!, {
+ sui: () => reader({ value: new Uint8Array([1, 2]), type: SUINS_NAME_RECORD_TYPE }),
+ })
+ ).rejects.toThrow('could not be read')
+ })
+
+ it('a failed read is a refusal, and the card warns a sold name keeps its target', async () => {
+ await expect(
+ resolveName('sui', recipientName('ada.sui')!, { sui: () => reader('broken') })
+ ).rejects.toThrow('could not be read')
+ const resolved = await resolveName('sui', recipientName('ada.sui')!, {
+ sui: () =>
+ reader({
+ value: encodeNameRecord({ expiresMs: future(), target: TARGET }),
+ type: SUINS_NAME_RECORD_TYPE,
+ }),
+ })
+ expect(resolved).toMatchObject({ name: 'ada.sui', address: TARGET, source: 'SuiNS' })
+ expect(resolved.caution).toContain('sold')
+ })
+})
+
+describe('resolveName', () => {
+ it('routes .sol and .sns to SNS, with the .sol registry only behind .sol', async () => {
+ const world = await snsWorld()
+ await expect(
+ resolveName('solana', recipientName('ada.sns')!, { solana: () => world })
+ ).resolves.toMatchObject({ address: owner.address, source: 'the Solana Name Service' })
+ await expect(
+ resolveName('solana', recipientName('ada.sol')!, { solana: () => world })
+ ).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('a failed read is a refusal, never a guess', async () => {
+ const world = await snsWorld()
+ world.broken = true
+ await expect(
+ resolveName('solana', recipientName('ada.sns')!, { solana: () => world })
+ ).rejects.toThrow(
+ `ada.sns could not be read from the chain just now, so I won't guess where it points. Try again, or use the wallet address. ${NOTHING}`
+ )
+ })
+
+ it('refuses anything a program holds rather than a wallet', async () => {
+ const { domain } = await snsAccounts('ada')
+ const world = await snsWorld('ada', domain)
+ await expect(
+ resolveName('solana', recipientName('ada.sns')!, { solana: () => world })
+ ).rejects.toThrow('held by a program')
+ })
+
+ it('will not run a name on the wrong chain even if asked directly', async () => {
+ await expect(resolveName('sui', recipientName('ada.sol')!, {})).rejects.toThrow(
+ 'is a Solana name'
+ )
+ })
+})
+
+describe('a resolved address gets every check a typed address gets', () => {
+ it('refuses a name that points at a token contract, the zero address or your own wallet', async () => {
+ const mint = SOLANA_TOKENS.find((t) => t.symbol === 'USDC')?.id as string
+ for (const [target, words] of [
+ [mint, 'not an address that can receive'],
+ [ZERO, 'not an address that can receive'],
+ [me, 'your own wallet'],
+ ] as const) {
+ await expect(
+ resolveRecipient('solana', 'USDC', 'ada.sns', me, {
+ solana: () => ({
+ accounts: async (addresses: readonly string[]) =>
+ addresses.map((_, i) =>
+ i === 0 ? { program: NAME_PROGRAM, data: registry(SNS_ROOT, target) } : null
+ ),
+ tokenAccounts: async () => [],
+ }),
+ })
+ ).rejects.toThrow(words)
+ }
+ })
+
+ it('says which name led there', async () => {
+ const world = await snsWorld('ada', me)
+ await expect(
+ resolveRecipient('solana', 'USDC', 'ada.sns', me, { solana: () => world })
+ ).rejects.toThrow(`ada.sns points to ${me}.`)
+ })
+
+ it('leaves a typed address alone and does not read anything', async () => {
+ const world = await snsWorld()
+ await expect(
+ resolveRecipient('solana', 'USDC', wallet, me, { solana: () => world })
+ ).resolves.toBeUndefined()
+ expect(world.reads).toBe(0)
+ })
+})
+
+describe('the card', () => {
+ const prepared: PreparedAction = {
+ venueId: 'send',
+ venueName: 'Wallet transfer',
+ kind: 'send',
+ summary: 'Send 1 USDC to WALLET.',
+ pay: { amount: '1', symbol: 'USDC' },
+ notes: ['Existing note.'],
+ signing: { family: 'solana', transaction: 'x' },
+ context: { recipient: 'WALLET' },
+ }
+ const named = { name: 'ada.sol', address: 'WALLET', source: 'the Solana Name Service' }
+
+ it('names the address and where it came from', () => {
+ const card = withResolvedName(prepared, named)
+ expect(card.summary).toBe('Send 1 USDC to ada.sol (WALLET).')
+ expect(card.notes[0]).toContain('ada.sol points to WALLET')
+ expect(card.notes[0]).toContain('the Solana Name Service')
+ expect(card.notes).toContain('Existing note.')
+ })
+
+ it('adds the address where a summary leaves it out', () => {
+ const card = withResolvedName({ ...prepared, summary: 'Stream 1 USDC for 30 days.' }, named)
+ expect(card.summary).toBe('Stream 1 USDC for 30 days. ada.sol is WALLET.')
+ })
+
+ it('refuses a venue that issued anything but the address the name read to', () => {
+ expect(() =>
+ withResolvedName({ ...prepared, context: { recipient: 'SOMEONE_ELSE' } }, named)
+ ).toThrow(ChainActionRefused)
+ })
+})
+
+/** A module whose venues are given, reading nothing else. */
+function moduleWith(chain: ChainWithActions, venues: ChainVenue[]): ChainModule {
+ return {
+ chain,
+ tokens: SOLANA_TOKENS,
+ venues,
+ defaultVenue: { send: venues[0]?.id ?? 'send' },
+ priceUsd: async () => 1,
+ submit: async () => ({ ok: false, error: 'unused' }),
+ balances: async () => ({ tokens: [], complete: false }),
+ }
+}
+
+function sendVenueStub(id: string, kinds: ChainActionKind[] = ['send', 'stream', 'invoice']) {
+ const seen: ChainActionRequest[] = []
+ const venue: ChainVenue & { seen: ChainActionRequest[] } = {
+ id,
+ name: id,
+ kinds,
+ seen,
+ prepare: async (request) => {
+ seen.push(request)
+ return {
+ venueId: id,
+ venueName: id,
+ kind: request.kind,
+ summary: `${request.kind} over time.`,
+ pay: { amount: request.amount, symbol: request.tokenIn },
+ notes: [],
+ signing: { family: 'solana', transaction: 'x' },
+ context: { recipient: request.recipient as string },
+ }
+ },
+ assertSafe: () => undefined,
+ }
+ return venue
+}
+
+describe('through prepareChainAction', () => {
+ let world: World
+ let methods: string[]
+ let executable: boolean
+
+ beforeEach(async () => {
+ world = await snsWorld()
+ methods = []
+ executable = false
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (_input: string | URL, init?: RequestInit) => {
+ const { method, params } = JSON.parse(init?.body as string) as {
+ method: string
+ params: unknown[]
+ }
+ methods.push(method)
+ const b64 = (data: Uint8Array): string => Buffer.from(data).toString('base64')
+ const reply = (result: unknown): Response =>
+ new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result }))
+ if (method === 'getMultipleAccounts') {
+ const found = await world.accounts(params[0] as string[])
+ return reply({
+ context: { slot: 1 },
+ value: found.map((account) =>
+ account === null
+ ? null
+ : { owner: account.program, data: [b64(account.data), 'base64'] }
+ ),
+ })
+ }
+ if (method === 'getAccountInfo') {
+ return reply({
+ context: { slot: 1 },
+ value: executable ? { owner: 'x', executable } : null,
+ })
+ }
+ if (method === 'getLatestBlockhash') {
+ return reply({
+ context: { slot: 1 },
+ value: {
+ blockhash: 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG',
+ lastValidBlockHeight: 9,
+ },
+ })
+ }
+ throw new Error(`unexpected ${method}`)
+ })
+ )
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ const request: ChainActionRequest = {
+ kind: 'send',
+ amount: '50',
+ tokenIn: 'USDC',
+ recipient: 'ada.sns',
+ }
+
+ it('builds to the resolved address, binds the proof to it, and shows it', async () => {
+ const venue = createSendVenue('solana')
+ const prepared = await prepareChainAction(moduleWith('solana', [venue]), request, me)
+
+ expect(prepared.context?.['recipient']).toBe(owner.address)
+ expect(prepared.context?.['proof']).toBeDefined()
+ expect(prepared.summary).toBe(`Send 50 USDC to ada.sns (${owner.address}).`)
+ expect(prepared.notes[0]).toContain(`ada.sns points to ${owner.address}`)
+ expect(prepared.notes[0]).toContain('the Solana Name Service')
+
+ expect(() => venue.assertSafe(prepared.signing, me, prepared.context)).not.toThrow()
+ expect(() =>
+ venue.assertSafe(prepared.signing, me, { ...prepared.context, recipient: 'ada.sns' })
+ ).toThrow(ChainActionRefused)
+ expect(() =>
+ venue.assertSafe(prepared.signing, me, { ...prepared.context, recipient: wallet })
+ ).toThrow(ChainActionRefused)
+ })
+
+ it('reads the name again each time, so a moved name is what is shown', async () => {
+ const venue = createSendVenue('solana')
+ const api = moduleWith('solana', [venue])
+ const first = await prepareChainAction(api, request, me)
+ const { domain } = await snsAccounts('ada')
+ world.set(domain, NAME_PROGRAM, registry(SNS_ROOT, wallet))
+ const second = await prepareChainAction(api, request, me)
+ expect(first.context?.['recipient']).toBe(owner.address)
+ expect(second.context?.['recipient']).toBe(wallet)
+ expect(() => venue.assertSafe(second.signing, me, first.context)).toThrow(ChainActionRefused)
+ })
+
+ it('still stops at the receiver check the build makes', async () => {
+ executable = true
+ await expect(
+ prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me)
+ ).rejects.toThrow('That address is a program')
+ })
+
+ it('builds nothing when the connection drops', async () => {
+ world.broken = true
+ await expect(
+ prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me)
+ ).rejects.toThrow("Solana didn't answer in time. Nothing was signed or sent")
+ expect(methods).not.toContain('getLatestBlockhash')
+ })
+
+ it.each([
+ ['an RPC error', () => new Response('{"jsonrpc":"2.0","id":1,"error":{"code":-32005}}')],
+ ['a 500', () => new Response('oops', { status: 500 })],
+ ['an answer of the wrong shape', () => new Response('{"result":{"value":[{"owner":1}]}}')],
+ ['too few accounts', () => new Response('{"result":{"value":[null]}}')],
+ [
+ 'data in another encoding',
+ () =>
+ new Response('{"result":{"value":[{"owner":"x","data":["00","base58"]},null,null,null]}}'),
+ ],
+ ])('builds nothing on %s', async (_what, answer) => {
+ const earlier = vi.mocked(fetch).getMockImplementation()
+ vi.mocked(fetch).mockImplementation(async (input, init) => {
+ const { method } = JSON.parse(init?.body as string) as { method: string }
+ methods.push(method)
+ if (method === 'getMultipleAccounts') return answer()
+ return (earlier as typeof fetch)(input, init)
+ })
+ await expect(
+ prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me)
+ ).rejects.toThrow('ada.sns could not be read from the chain just now')
+ expect(methods).not.toContain('getLatestBlockhash')
+ })
+
+ it('builds nothing on a tampered record', async () => {
+ await setV2(world, wallet, oldOwner.address)
+ await expect(
+ prepareChainAction(moduleWith('solana', [createSendVenue('solana')]), request, me)
+ ).rejects.toThrow('previous owner')
+ expect(methods).not.toContain('getLatestBlockhash')
+ })
+
+ it('streams and invoices go the same way', async () => {
+ const venue = sendVenueStub('fake')
+ const api: ChainModule = {
+ ...moduleWith('solana', [venue]),
+ defaultVenue: { stream: 'fake', invoice: 'fake' },
+ }
+ for (const kind of ['stream', 'invoice'] as const) {
+ const prepared = await prepareChainAction(api, { ...request, kind }, me)
+ expect(venue.seen.at(-1)?.recipient).toBe(owner.address)
+ expect(prepared.summary).toContain(`ada.sns is ${owner.address}.`)
+ expect(prepared.notes[0]).toContain(`ada.sns points to ${owner.address}`)
+ }
+ })
+
+ it('refuses a venue that kept the typed name or changed the address', async () => {
+ const venue: ChainVenue = {
+ ...sendVenueStub('fake'),
+ prepare: async (r) => ({
+ venueId: 'fake',
+ venueName: 'fake',
+ kind: r.kind,
+ summary: 'x',
+ pay: { amount: r.amount, symbol: r.tokenIn },
+ notes: [],
+ signing: { family: 'solana', transaction: 'x' },
+ context: { recipient: wallet },
+ }),
+ }
+ await expect(prepareChainAction(moduleWith('solana', [venue]), request, me)).rejects.toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('a kind that names no one still refuses a name', async () => {
+ await expect(
+ prepareChainAction(
+ { ...moduleWith('solana', [sendVenueStub('fake', ['swap'])]) },
+ { ...request, kind: 'supply' },
+ me
+ )
+ ).rejects.toThrow("doesn't take an address")
+ })
+
+ it('the send venue will not take a name that was not read first', async () => {
+ const venue = createSendVenue('solana')
+ await expect(venue.prepare(request, me)).rejects.toThrow(ChainActionRefused)
+ expect(methods).toEqual([])
+ })
+
+ it('a follow-on reads the name before the swap, and refuses what it finds', async () => {
+ const venue = createSendVenue('solana')
+ await expect(
+ venue.assertCanFollow?.({ kind: 'send', tokenIn: 'USDC', recipient: 'ada.sns' }, me)
+ ).resolves.toBeUndefined()
+ expect(methods).toContain('getMultipleAccounts')
+ await setV2(world, wallet, oldOwner.address)
+ await expect(
+ venue.assertCanFollow?.({ kind: 'send', tokenIn: 'USDC', recipient: 'ada.sns' }, me)
+ ).rejects.toThrow('previous owner')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/rhea.live.test.ts b/apps/dapp/lib/__tests__/rhea.live.test.ts
new file mode 100644
index 00000000..eecd8173
--- /dev/null
+++ b/apps/dapp/lib/__tests__/rhea.live.test.ts
@@ -0,0 +1,290 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { rheaLendVenue } from '../chains/near/rhea-lend'
+import {
+ RHEA_PRICE_AGE_SEC,
+ readPythInfos,
+ readTokenPrice,
+ usdPerToken,
+} from '../chains/near/rhea-read'
+import {
+ RHEA_DEX,
+ RHEA_EXTRA_DECIMALS,
+ RHEA_LEND,
+ RHEA_LEND_STORAGE_YOCTO,
+ RHEA_POOLS,
+ RHEA_PRICE_ORACLE,
+} from '../chains/near/rhea-safety'
+import { rheaDexVenue } from '../chains/near/rhea-swap'
+import { viewCall } from '../chains/near/rpc'
+import { STORAGE_DEPOSIT_YOCTO } from '../chains/near/safety'
+import { USDC_NEAR, USDT_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+
+/**
+ * The real contracts, read-only. NEAR's RPC has no way to run a call that writes, so nothing here
+ * proves a swap or a loan would land: it proves the contracts, pools, prices and methods this app
+ * relies on are what it thinks, and that what prepare builds from live state passes its own check.
+ * Nothing signs or sends.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const RPC = process.env['NEAR_RPC_URL'] ?? 'https://free.rpc.fastnear.com'
+/** Holds a Rhea Lend position with a loan on it at the time of writing. Only ever read. */
+const BORROWER = '47a356e729c4c8afa3252deb140e1f48de066a0da6284aa2a82723a8380f644e'
+const NOBODY = 'root.near'
+
+/** The public RPC answers 429 once this suite has read enough, so it goes gently and looks again. */
+const PAUSE_MS = 2_500
+const LOOK_AGAIN = { retry: 2 }
+
+const codeOf = new Map()
+
+async function exportedMethods(contract: string): Promise {
+ const known = codeOf.get(contract)
+ if (known !== undefined) return known
+ const response = await fetch(RPC, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 'test',
+ method: 'query',
+ params: { request_type: 'view_code', finality: 'final', account_id: contract },
+ }),
+ signal: AbortSignal.timeout(30_000),
+ })
+ const body = (await response.json()) as { result: { code_base64: string } }
+ const code = Buffer.from(body.result.code_base64, 'base64').toString('latin1')
+ codeOf.set(contract, code)
+ return code
+}
+
+beforeEach(async () => {
+ if (!SKIP) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
+})
+
+describe.skipIf(SKIP)('Rhea DEX on mainnet', LOOK_AGAIN, () => {
+ it('is running at the version this app read, with no token frozen', async () => {
+ const meta = await viewCall<{
+ state: string
+ version: string
+ owner: string
+ wnear_id: string
+ }>(RHEA_DEX, 'metadata')
+ expect(meta.state).toBe('Running')
+ expect(meta.version).toBe('1.9.21')
+ expect(meta.owner).toBe('ref-finance.sputnik-dao.near')
+ expect(meta.wnear_id).toBe(WRAP_NEAR)
+ const frozen = await viewCall(RHEA_DEX, 'get_frozenlist_tokens')
+ for (const token of [WRAP_NEAR, USDC_NEAR, USDT_NEAR]) expect(frozen).not.toContain(token)
+ }, 60_000)
+
+ it('still holds the tokens each pinned pool is pinned to', async () => {
+ const ids = Object.keys(RHEA_POOLS).map(Number)
+ const pools = await viewCall<{ token_account_ids: string[]; amounts: string[] }[]>(
+ RHEA_DEX,
+ 'get_pool_by_ids',
+ { pool_ids: ids }
+ )
+ ids.forEach((id, index) => {
+ const pool = pools[index] as { token_account_ids: string[] }
+ for (const token of RHEA_POOLS[id] as readonly string[]) {
+ expect(pool.token_account_ids, `pool ${id}`).toContain(token)
+ }
+ })
+ }, 60_000)
+
+ it('still has the message fields the policy reasons about, and the exits it builds', async () => {
+ const code = await exportedMethods(RHEA_DEX)
+ for (const field of [
+ 'swap_out_recipient',
+ 'client_echo',
+ 'skip_unwrap_near',
+ 'skip_degen_price_sync',
+ 'extra_tgas_for_client_echo',
+ 'hot_zap_actions',
+ ]) {
+ expect(code, field).toContain(field)
+ }
+ // The message enum has seven fields in the source this app read. An eighth could move money.
+ expect(code).toContain('actionsclient_echoswap_out_recipientskip_degen_price_sync')
+ for (const method of [
+ 'ft_on_transfer',
+ 'withdraw',
+ 'claim_lostfound',
+ 'list_lostfound_tokens',
+ 'get_deposits',
+ 'get_return',
+ 'get_pool_by_ids',
+ ]) {
+ expect(code, method).toContain(method)
+ }
+ }, 120_000)
+
+ it.each([
+ ['NEAR', 'USDC', '2'],
+ ['NEAR', 'USDT', '2'],
+ ['USDC', 'NEAR', '5'],
+ ['USDT', 'NEAR', '5'],
+ ['USDC', 'USDT', '5'],
+ ['USDT', 'USDC', '5'],
+ ])(
+ 'quotes %s to %s from the chain, near the Pyth price',
+ async (tokenIn, tokenOut, amount) => {
+ const quoted = await rheaDexVenue.quote?.({ kind: 'swap', amount, tokenIn, tokenOut }, NOBODY)
+ expect(quoted?.receive?.symbol).toBe(tokenOut)
+ expect(Number(quoted?.receive?.amount)).toBeGreaterThan(0)
+ },
+ 60_000
+ )
+
+ it('builds a swap from NEAR that passes its own check', async () => {
+ const prepared = await rheaDexVenue.prepare(
+ { kind: 'swap', amount: '2', tokenIn: 'NEAR', tokenOut: 'USDC' },
+ NOBODY
+ )
+ expect(() => rheaDexVenue.assertSafe(prepared.signing, NOBODY)).not.toThrow()
+ if (prepared.signing.family !== 'near') throw new Error('wrong family')
+ const swap = prepared.signing.transactions.at(-1)
+ expect(swap?.receiverId).toBe(WRAP_NEAR)
+ expect(swap?.actions.at(-1)?.params.args['receiver_id']).toBe(RHEA_DEX)
+ }, 60_000)
+
+ it('reads an account with nothing held there as nothing', async () => {
+ await expect(rheaDexVenue.positions?.('nobody-here-at-all.near')).resolves.toEqual([])
+ }, 60_000)
+})
+
+describe.skipIf(SKIP)('Rhea Lend on mainnet', LOOK_AGAIN, () => {
+ it('is at the version this app read', async () => {
+ await expect(viewCall(RHEA_LEND, 'get_version')).resolves.toBe('0.16.2')
+ }, 60_000)
+
+ it('runs on Pyth prices, from the pinned oracle, with the old oracle route off', async () => {
+ const config = await viewCall<{
+ contract_mode: string
+ enable_price_oracle: boolean
+ enable_pyth_oracle: boolean
+ pyth_oracle_account_id: string
+ pyth_price_valid_duration_sec: number
+ ref_exchange_id: string
+ owner_id: string
+ }>(RHEA_LEND, 'get_config')
+ expect(config.contract_mode).toBe('Running')
+ expect(config.enable_price_oracle).toBe(false)
+ expect(config.enable_pyth_oracle).toBe(true)
+ expect(config.pyth_oracle_account_id).toBe(RHEA_PRICE_ORACLE)
+ expect(config.pyth_price_valid_duration_sec).toBe(RHEA_PRICE_AGE_SEC)
+ expect(config.ref_exchange_id).toBe(RHEA_DEX)
+ expect(config.owner_id).toBe('burrow.sputnik-dao.near')
+ }, 60_000)
+
+ it('counts each token at the pinned scale and charges the pinned storage', async () => {
+ for (const token of [WRAP_NEAR, USDC_NEAR, USDT_NEAR]) {
+ const asset = await viewCall<{ config: { extra_decimals: number } }>(RHEA_LEND, 'get_asset', {
+ token_id: token,
+ })
+ expect(asset.config.extra_decimals, token).toBe(RHEA_EXTRA_DECIMALS[token])
+ }
+ const bounds = await viewCall<{ min: string }>(RHEA_LEND, 'storage_balance_bounds')
+ expect(bounds.min).toBe(RHEA_LEND_STORAGE_YOCTO)
+ const wrap = await viewCall<{ min: string }>(WRAP_NEAR, 'storage_balance_bounds')
+ expect(wrap.min).toBe(STORAGE_DEPOSIT_YOCTO)
+ }, 60_000)
+
+ it('has a fresh price for every listed token, in a believable range', async () => {
+ const infos = await readPythInfos()
+ const near = usdPerToken(await readTokenPrice(WRAP_NEAR, infos), 24)
+ const usdc = usdPerToken(await readTokenPrice(USDC_NEAR, infos), 6)
+ const usdt = usdPerToken(await readTokenPrice(USDT_NEAR, infos), 6)
+ expect(near).toBeGreaterThan(0.01)
+ expect(near).toBeLessThan(10_000)
+ expect(usdc).toBeGreaterThan(0.9)
+ expect(usdc).toBeLessThan(1.1)
+ expect(usdt).toBeGreaterThan(0.9)
+ expect(usdt).toBeLessThan(1.1)
+ }, 60_000)
+
+ it('still has the call shapes this app sends, and the message variants', async () => {
+ const code = await exportedMethods(RHEA_LEND)
+ for (const name of [
+ 'execute_with_pyth',
+ 'get_account_all_positions',
+ 'get_all_token_pyth_infos',
+ 'IncreaseCollateral',
+ 'DecreaseCollateral',
+ 'Borrow',
+ 'Withdraw',
+ 'Repay',
+ ]) {
+ expect(code, name).toContain(name)
+ }
+ expect(await exportedMethods(WRAP_NEAR)).toContain('near_withdraw')
+ }, 120_000)
+
+ it('reads a real position with a loan on it', async () => {
+ const held = (await rheaLendVenue.positions?.(BORROWER)) ?? []
+ expect(held.some((p) => p.slot === 'supplied')).toBe(true)
+ expect(held.some((p) => p.slot === 'borrowed')).toBe(true)
+ for (const position of held) expect(Number(position.amount)).toBeGreaterThan(0)
+ }, 60_000)
+
+ it('builds a small borrow against a real position, checked with live prices', async () => {
+ const borrow = await rheaLendVenue.prepare(
+ { kind: 'borrow', amount: '0.1', tokenIn: 'USDC' },
+ BORROWER
+ )
+ expect(() => rheaLendVenue.assertSafe(borrow.signing, BORROWER)).not.toThrow()
+ expect(borrow.notes.join(' ')).toMatch(/You can lose your collateral/)
+ if (borrow.signing.family !== 'near') throw new Error('wrong family')
+ const call = borrow.signing.transactions.at(-1)?.actions.at(-1)?.params
+ expect(call?.methodName).toBe('execute_with_pyth')
+ expect(call?.args['actions']).toEqual([
+ { Borrow: { token_id: USDC_NEAR, amount: '100000000000000000' } },
+ { Withdraw: { token_id: USDC_NEAR, max_amount: '100000000000000000' } },
+ ])
+ }, 60_000)
+
+ it('builds a small withdrawal from real collateral, which needs prices', async () => {
+ const out = await rheaLendVenue.prepare(
+ { kind: 'withdraw', amount: '0.01', tokenIn: 'USDC' },
+ BORROWER
+ )
+ expect(() => rheaLendVenue.assertSafe(out.signing, BORROWER)).not.toThrow()
+ if (out.signing.family !== 'near') throw new Error('wrong family')
+ expect(out.signing.transactions.at(-1)?.actions.at(-1)?.params.methodName).toBe(
+ 'execute_with_pyth'
+ )
+ }, 60_000)
+
+ it('builds paying off a real loan, or says the wallet holds too little', async () => {
+ const held = (await rheaLendVenue.positions?.(BORROWER)) ?? []
+ const owed = held.find((p) => p.slot === 'borrowed')
+ if (owed === undefined) throw new Error('that account no longer has a loan to read')
+ // Only a refusal about the wallet counts. One saying the chain couldn't be read proves nothing.
+ await rheaLendVenue
+ .prepare(
+ { kind: 'repay', amount: owed.amount, wholePosition: true, tokenIn: owed.symbol },
+ BORROWER
+ )
+ .then(
+ (repay) => expect(() => rheaLendVenue.assertSafe(repay.signing, BORROWER)).not.toThrow(),
+ (refusal: Error) => expect(refusal.message).toMatch(/You hold/)
+ )
+ }, 60_000)
+
+ it('refuses to lend to an account with nothing behind it', async () => {
+ await expect(
+ rheaLendVenue.prepare({ kind: 'borrow', amount: '1', tokenIn: 'USDC' }, NOBODY)
+ ).rejects.toThrow(/Nothing of yours backs a loan/)
+ }, 60_000)
+
+ it('builds a supply for an account that has never used it, paying for its record', async () => {
+ const prepared = await rheaLendVenue.prepare(
+ { kind: 'supply', amount: '2', tokenIn: 'NEAR' },
+ NOBODY
+ )
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, NOBODY)).not.toThrow()
+ expect(prepared.apy?.basePercent).toBeGreaterThanOrEqual(0)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/rhea.test.ts b/apps/dapp/lib/__tests__/rhea.test.ts
new file mode 100644
index 00000000..56ef32f8
--- /dev/null
+++ b/apps/dapp/lib/__tests__/rhea.test.ts
@@ -0,0 +1,1539 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { nearModule } from '../chains/near/module'
+import { confirmRheaLend, rheaLendVenue } from '../chains/near/rhea-lend'
+import { rheaDexListing, rheaLendListing } from '../chains/near/rhea-listing'
+import {
+ assertRheaDexSafe,
+ assertRheaLendSafe,
+ RHEA_DEX,
+ RHEA_LEND,
+ RHEA_LEND_STORAGE_YOCTO,
+} from '../chains/near/rhea-safety'
+import { confirmRheaDex, rheaDexVenue } from '../chains/near/rhea-swap'
+import { USDC_NEAR, USDT_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+import { prepareChainAction } from '../chains/prepare'
+import {
+ ChainActionRefused,
+ type ChainActionRequest,
+ type ChainModule,
+ type NearTransaction,
+ type PreparedAction,
+ type SigningPayload,
+} from '../chains/types'
+
+const ME = 'alice.near'
+const THEM = 'mallory.near'
+const HASH = 'G6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScA'
+const HASH2 = 'H6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScB'
+const STORAGE = '1250000000000000000000'
+const ONE_NEAR = BigInt('1000000000000000000000000')
+const NEAR_ID = 'c415de8d2eba7db216527dff4b60e8f3a5311c740dadb233e13e12547e226750'
+const USDC_ID = 'eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a'
+const USDT_ID = '2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b'
+
+const dollars = (n: number): string => `${n}000000`
+const inner18 = (n: number): string => (BigInt(n) * BigInt(10) ** BigInt(18)).toString()
+const yocto = (n: number): string => (BigInt(n) * ONE_NEAR).toString()
+
+type View = (args: Record) => unknown
+
+interface Rpc {
+ views: Record
+ down: boolean
+ tx: ((hash: string) => unknown) | undefined
+}
+
+let rpc: Rpc
+let quotes: Record
+let account: unknown
+let assets: Record>
+
+function asset(token: string, ratio: number, extra: number): Record {
+ const unit = token === WRAP_NEAR ? ONE_NEAR : BigInt(10) ** BigInt(18)
+ return {
+ token_id: token,
+ supplied: { balance: (BigInt(1_000_000) * unit).toString() },
+ borrowed: { balance: (BigInt(800_000) * unit).toString() },
+ margin_debt: { balance: '0' },
+ margin_pending_debt: '0',
+ reserved: '0',
+ prot_fee: '0',
+ supply_apr: '0.0985',
+ borrow_apr: '0.1524',
+ config: {
+ extra_decimals: extra,
+ volatility_ratio: ratio,
+ can_deposit: true,
+ can_withdraw: true,
+ can_use_as_collateral: true,
+ can_borrow: true,
+ supplied_limit: null,
+ borrowed_limit: null,
+ min_borrowed_amount: '1000',
+ },
+ }
+}
+
+function pool(tokens: string[], fee = 30): Record {
+ return { token_account_ids: tokens, amounts: ['1', '1'], total_fee: fee }
+}
+
+const POOLS: Record> = {
+ 4512: pool([USDC_NEAR, WRAP_NEAR]),
+ 5515: pool([WRAP_NEAR, USDC_NEAR]),
+ 6063: pool([WRAP_NEAR, USDT_NEAR]),
+ 6416: pool([USDT_NEAR, USDC_NEAR], 1),
+ 4179: pool([USDT_NEAR, USDC_NEAR, 'dac.factory.bridge.near', 'a0b.factory.bridge.near'], 2),
+}
+
+function baseViews(): Record {
+ const balance = (): string => dollars(1000)
+ const registered = (): unknown => ({ total: STORAGE, available: '0' })
+ return {
+ [`${RHEA_DEX}.metadata`]: () => ({ state: 'Running' }),
+ [`${RHEA_DEX}.get_frozenlist_tokens`]: () => [],
+ [`${RHEA_DEX}.get_pool_by_ids`]: ({ pool_ids }) =>
+ (pool_ids as number[]).map((id) => POOLS[id]),
+ [`${RHEA_DEX}.get_return`]: ({ pool_id }) => {
+ const out = quotes[pool_id as number]
+ if (out === undefined) throw new Error('no quote')
+ return out
+ },
+ [`${RHEA_DEX}.get_deposits`]: () => ({}),
+ [`${RHEA_DEX}.list_lostfound_tokens`]: () => ({}),
+ [`${RHEA_LEND}.get_config`]: () => ({
+ contract_mode: 'Running',
+ enable_pyth_oracle: true,
+ pyth_oracle_account_id: 'aggregate-oracle.near',
+ }),
+ [`${RHEA_LEND}.get_account_all_positions`]: () => account,
+ [`${RHEA_LEND}.get_asset`]: ({ token_id }) => assets[token_id as string],
+ [`${RHEA_LEND}.storage_balance_of`]: registered,
+ [`${RHEA_LEND}.get_all_token_pyth_infos`]: () => ({
+ [WRAP_NEAR]: {
+ decimals: 24,
+ fraction_digits: 4,
+ price_identifier: NEAR_ID,
+ extra_call: null,
+ default_price: null,
+ },
+ [USDC_NEAR]: {
+ decimals: 6,
+ fraction_digits: 4,
+ price_identifier: USDC_ID,
+ extra_call: null,
+ default_price: null,
+ },
+ [USDT_NEAR]: {
+ decimals: 6,
+ fraction_digits: 4,
+ price_identifier: USDT_ID,
+ extra_call: null,
+ default_price: null,
+ },
+ }),
+ 'aggregate-oracle.near.get_price_no_older_than': ({ price_id }) => ({
+ price: price_id === NEAR_ID ? '400000000' : '100000000',
+ expo: -8,
+ }),
+ [`${WRAP_NEAR}.storage_balance_of`]: registered,
+ [`${USDC_NEAR}.storage_balance_of`]: registered,
+ [`${USDT_NEAR}.storage_balance_of`]: registered,
+ [`${WRAP_NEAR}.ft_balance_of`]: () => '0',
+ [`${USDC_NEAR}.ft_balance_of`]: balance,
+ [`${USDT_NEAR}.ft_balance_of`]: balance,
+ }
+}
+
+function install(): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (_input: string | URL, init?: RequestInit) => {
+ const json = (data: unknown): Response => new Response(JSON.stringify(data))
+ if (rpc.down) throw new Error('down')
+ const body = JSON.parse(init?.body as string) as {
+ method: string
+ params: {
+ account_id?: string
+ method_name?: string
+ args_base64?: string
+ tx_hash?: string
+ }
+ }
+ if (body.method === 'tx') {
+ const result = rpc.tx?.(body.params.tx_hash as string)
+ return json(
+ result === undefined ? { error: { cause: { name: 'UNKNOWN_TRANSACTION' } } } : { result }
+ )
+ }
+ const view = rpc.views[`${body.params.account_id}.${body.params.method_name}`]
+ if (view === undefined) return json({ result: { error: 'no such view' } })
+ const args = JSON.parse(
+ Buffer.from(body.params.args_base64 as string, 'base64').toString('utf8')
+ ) as Record
+ try {
+ return json({ result: { result: [...Buffer.from(JSON.stringify(view(args)))] } })
+ } catch {
+ return json({ result: { error: 'wasm execution failed' } })
+ }
+ })
+ )
+}
+
+beforeEach(() => {
+ account = null
+ assets = {
+ [WRAP_NEAR]: asset(WRAP_NEAR, 7500, 0),
+ [USDC_NEAR]: asset(USDC_NEAR, 9500, 12),
+ [USDT_NEAR]: asset(USDT_NEAR, 9500, 12),
+ }
+ quotes = {
+ 4512: dollars(4),
+ 5515: dollars(3),
+ 6063: dollars(4),
+ 6416: dollars(1),
+ 4179: '990000',
+ }
+ rpc = { views: baseViews(), down: false, tx: undefined }
+ install()
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
+const txs = (prepared: { signing: SigningPayload }): NearTransaction[] =>
+ (prepared.signing as { transactions: NearTransaction[] }).transactions
+
+const methods = (tx: NearTransaction | undefined): string[] =>
+ (tx?.actions ?? []).map((a) => a.params.methodName)
+
+const lastCall = (tx: NearTransaction | undefined): NearTransaction['actions'][number]['params'] =>
+ (tx?.actions.at(-1) as NearTransaction['actions'][number]).params
+
+const swap = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'swap',
+ amount: '1',
+ tokenIn: 'NEAR',
+ tokenOut: 'USDC',
+ ...over,
+})
+
+interface SwapMessage {
+ actions: Record[]
+ skip_unwrap_near?: boolean
+}
+
+function messageOf(prepared: PreparedAction): SwapMessage {
+ return JSON.parse(lastCall(txs(prepared).at(-1)).args['msg'] as string) as SwapMessage
+}
+
+describe('swapping on Rhea DEX', () => {
+ it('wraps NEAR and sends it to the pinned exchange, with a real minimum', async () => {
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(() => rheaDexVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [tx] = txs(prepared)
+ expect(txs(prepared)).toHaveLength(1)
+ expect(tx?.receiverId).toBe(WRAP_NEAR)
+ expect(methods(tx)).toEqual(['near_deposit', 'ft_transfer_call'])
+ expect(tx?.actions[0]?.params.deposit).toBe(yocto(1))
+ expect(lastCall(tx).deposit).toBe('1')
+ expect(lastCall(tx).args['receiver_id']).toBe(RHEA_DEX)
+ expect(lastCall(tx).args['amount']).toBe(yocto(1))
+
+ const message = messageOf(prepared)
+ expect(Object.keys(message)).toEqual(['actions'])
+ expect(message.actions).toEqual([
+ {
+ pool_id: 4512,
+ token_in: WRAP_NEAR,
+ token_out: USDC_NEAR,
+ amount_in: yocto(1),
+ min_amount_out: '3960000',
+ },
+ ])
+ expect(prepared.receive).toEqual({ amount: '4', symbol: 'USDC', approximate: true })
+ })
+
+ it('takes the route that pays most, read from the chain', async () => {
+ quotes = { 4512: dollars(3), 5515: '3950000', 6063: '3900000', 6416: '3890000' }
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(messageOf(prepared).actions[0]?.['pool_id']).toBe(5515)
+ })
+
+ it('crosses two pools with one minimum at the end, when that pays most', async () => {
+ quotes = { 4512: dollars(3), 5515: dollars(3), 6063: dollars(4), 6416: '3990000' }
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(() => rheaDexVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const { actions } = messageOf(prepared)
+ expect(actions.map((a) => a['pool_id'])).toEqual([6063, 6416])
+ expect(actions[0]?.['min_amount_out']).toBe('0')
+ expect(actions[1]).not.toHaveProperty('amount_in')
+ expect(actions[1]?.['min_amount_out']).toBe('3950100')
+ })
+
+ it('leaves out a pool that will not quote, as one with a stale price will not', async () => {
+ quotes = { 4512: undefined, 5515: dollars(4), 6063: undefined }
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(messageOf(prepared).actions[0]?.['pool_id']).toBe(5515)
+ })
+
+ it('refuses when no pool will quote', async () => {
+ quotes = {}
+ await expect(rheaDexVenue.prepare(swap(), ME)).rejects.toThrow(/no pool that will quote/)
+ })
+
+ it('asks for NEAR back as NEAR, so it needs no registration and can not bounce', async () => {
+ quotes = { 4512: yocto(1), 5515: yocto(1), 6063: yocto(1), 6416: dollars(4) }
+ const prepared = await rheaDexVenue.prepare(
+ swap({ amount: '4', tokenIn: 'USDC', tokenOut: 'NEAR' }),
+ ME
+ )
+ expect(() => rheaDexVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(txs(prepared)).toHaveLength(1)
+ expect(txs(prepared)[0]?.receiverId).toBe(USDC_NEAR)
+ expect(messageOf(prepared).skip_unwrap_near).toBe(false)
+ })
+
+ it('registers you on the token coming out first, so it can reach you', async () => {
+ rpc.views[`${USDC_NEAR}.storage_balance_of`] = () => null
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(() => rheaDexVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [first, second] = txs(prepared)
+ expect(first?.receiverId).toBe(USDC_NEAR)
+ expect(methods(first)).toEqual(['storage_deposit'])
+ expect(first?.actions[0]?.params.args).toEqual({ account_id: ME, registration_only: true })
+ expect(first?.actions[0]?.params.deposit).toBe(STORAGE)
+ expect(methods(second)).toEqual(['near_deposit', 'ft_transfer_call'])
+ expect(prepared.notes.join(' ')).toMatch(/two transactions/)
+ })
+
+ it('pays for wNEAR storage in the same transaction as a first wrap', async () => {
+ rpc.views[`${WRAP_NEAR}.storage_balance_of`] = () => null
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(() => rheaDexVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(methods(txs(prepared)[0])).toEqual([
+ 'storage_deposit',
+ 'near_deposit',
+ 'ft_transfer_call',
+ ])
+ })
+
+ it('refuses more than the wallet holds', async () => {
+ quotes = { 6416: dollars(5000), 4179: dollars(4990) }
+ await expect(
+ rheaDexVenue.prepare(swap({ amount: '5000', tokenIn: 'USDC', tokenOut: 'USDT' }), ME)
+ ).rejects.toThrow(/You hold 1000 USDC/)
+ })
+
+ it('refuses a swap that loses too much against Pyth', async () => {
+ quotes = { 4512: dollars(3), 5515: dollars(3), 6063: dollars(3), 6416: dollars(3) }
+ await expect(rheaDexVenue.prepare(swap(), ME)).rejects.toThrow(/about 25.0% less/)
+ })
+
+ it('refuses a swap with no price to check it against', async () => {
+ rpc.views['aggregate-oracle.near.get_price_no_older_than'] = () => null
+ await expect(rheaDexVenue.prepare(swap(), ME)).rejects.toThrow(/checked against live prices/)
+ })
+
+ it('refuses while the exchange is paused or a token is frozen', async () => {
+ rpc.views[`${RHEA_DEX}.get_frozenlist_tokens`] = () => [USDC_NEAR]
+ await expect(rheaDexVenue.prepare(swap(), ME)).rejects.toThrow(/frozen/)
+ rpc.views[`${RHEA_DEX}.metadata`] = () => ({ state: 'Paused' })
+ await expect(rheaDexVenue.prepare(swap(), ME)).rejects.toThrow(/paused/)
+ })
+
+ it('says the network could not be read when it can not, and quotes without building', async () => {
+ const quoted = await rheaDexVenue.quote?.(swap(), ME)
+ expect(quoted).toEqual({ receive: { amount: '4', symbol: 'USDC' } })
+ rpc.down = true
+ await expect(rheaDexVenue.prepare(swap(), ME)).rejects.toThrow(/could not be read/)
+ })
+
+ it('refuses another account shape', async () => {
+ await expect(rheaDexVenue.prepare(swap(), 'NOT AN ACCOUNT')).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('the swap policy', () => {
+ function tampered(
+ change: (message: Record, args: Record) => void
+ ) {
+ return async (): Promise => {
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ const copy = JSON.parse(JSON.stringify(prepared.signing)) as {
+ family: 'near'
+ transactions: NearTransaction[]
+ }
+ const args = lastCall(copy.transactions.at(-1)).args
+ const message = JSON.parse(args['msg'] as string) as Record
+ change(message, args)
+ args['msg'] = args['rawMsg'] ?? JSON.stringify(message)
+ delete args['rawMsg']
+ expect(() => assertRheaDexSafe(copy, ME)).toThrow(ChainActionRefused)
+ }
+ }
+ const step = (m: Record): Record =>
+ (m['actions'] as Record[])[0] as Record
+
+ it('passes what prepare builds, once untouched', async () => {
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ expect(() => assertRheaDexSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it.each<[string, (m: Record, a: Record) => void]>([
+ ['proceeds sent to someone else', (m) => (m['swap_out_recipient'] = THEM)],
+ ['proceeds sent with a callback', (m) => (m['client_echo'] = 'x')],
+ ['a referral taking a cut', (m) => (m['referral_id'] = THEM)],
+ ['the price sync switched off', (m) => (m['skip_degen_price_sync'] = true)],
+ ['extra gas for a callback', (m) => (m['extra_tgas_for_client_echo'] = 1)],
+ ['a liquidity zap', (m) => (m['hot_zap_actions'] = [])],
+ ['a pool nobody checked', (m) => (step(m)['pool_id'] = 9999)],
+ ['no minimum', (m) => (step(m)['min_amount_out'] = '0')],
+ ['a token its pool does not trade', (m) => (step(m)['token_out'] = USDT_NEAR)],
+ ['an unlisted token', (m) => (step(m)['token_out'] = 'evil.near')],
+ ['less swapped than sent', (m) => (step(m)['amount_in'] = '1')],
+ ['a step starting elsewhere', (m) => (step(m)['token_in'] = USDC_NEAR)],
+ ['an extra field on a step', (m) => (step(m)['extra'] = 1)],
+ ['swap by output', (m) => (step(m)['amount_out'] = '1')],
+ ['no steps', (m) => (m['actions'] = [])],
+ ['three steps', (m) => (m['actions'] = [step(m), step(m), step(m)])],
+ ['the same pool twice', (m) => (m['actions'] = [step(m), step(m)])],
+ ['another receiver', (_m, a) => (a['receiver_id'] = THEM)],
+ ['another amount', (_m, a) => (a['amount'] = '1')],
+ ['an extra argument', (_m, a) => (a['memo'] = 'x')],
+ ['an empty message, which would strand it', (_m, a) => (a['rawMsg'] = '')],
+ ['a message written another way', (m, a) => (a['rawMsg'] = ` ${JSON.stringify(m)}`)],
+ ['a null message', (_m, a) => (a['rawMsg'] = 'null')],
+ ['a broken message', (_m, a) => (a['rawMsg'] = '{')],
+ ])('refuses %s', async (_name, change) => {
+ await tampered(change)()
+ })
+
+ async function nearOut(): Promise<{ family: 'near'; transactions: NearTransaction[] }> {
+ quotes = { 4512: yocto(1), 5515: yocto(1), 6063: yocto(1), 6416: dollars(4) }
+ const prepared = await rheaDexVenue.prepare(
+ swap({ amount: '4', tokenIn: 'USDC', tokenOut: 'NEAR' }),
+ ME
+ )
+ return JSON.parse(JSON.stringify(prepared.signing)) as {
+ family: 'near'
+ transactions: NearTransaction[]
+ }
+ }
+
+ it.each([
+ ['left wrapped', { skip_unwrap_near: true }],
+ ['with the flag missing', { skip_unwrap_near: undefined }],
+ ])('refuses NEAR handed back %s', async (_name, flag) => {
+ const copy = await nearOut()
+ const args = lastCall(copy.transactions[0]).args
+ args['msg'] = JSON.stringify({ ...(JSON.parse(args['msg'] as string) as object), ...flag })
+ expect(() => assertRheaDexSafe(copy, ME)).toThrow(/hand back NEAR|unexpected fields/)
+ })
+
+ it('refuses a minimum far under a stablecoin swap', async () => {
+ quotes = { 6416: dollars(10), 4179: dollars(9) }
+ const prepared = await rheaDexVenue.prepare(
+ swap({ amount: '10', tokenIn: 'USDC', tokenOut: 'USDT' }),
+ ME
+ )
+ expect(() => assertRheaDexSafe(prepared.signing, ME)).not.toThrow()
+ const copy = JSON.parse(JSON.stringify(prepared.signing)) as {
+ family: 'near'
+ transactions: NearTransaction[]
+ }
+ const args = lastCall(copy.transactions[0]).args
+ const message = JSON.parse(args['msg'] as string) as { actions: Record[] }
+ ;(message.actions[0] as Record)['min_amount_out'] = '1'
+ args['msg'] = JSON.stringify(message)
+ expect(() => assertRheaDexSafe(copy, ME)).toThrow(/far too little/)
+ })
+
+ it('refuses the wrap not matching, more wraps, a registration for someone else, a bad order', async () => {
+ const fresh = async (): Promise<{ family: 'near'; transactions: NearTransaction[] }> => {
+ rpc.views[`${USDC_NEAR}.storage_balance_of`] = () => null
+ const prepared = await rheaDexVenue.prepare(swap(), ME)
+ return JSON.parse(JSON.stringify(prepared.signing)) as {
+ family: 'near'
+ transactions: NearTransaction[]
+ }
+ }
+ const a = await fresh()
+ ;(a.transactions[1]?.actions[0]?.params as { deposit: string }).deposit = yocto(2)
+ expect(() => assertRheaDexSafe(a, ME)).toThrow(/amount wrapped/)
+
+ const b = await fresh()
+ ;(b.transactions[0]?.actions[0]?.params.args as Record)['account_id'] = THEM
+ expect(() => assertRheaDexSafe(b, ME)).toThrow(/another account/)
+
+ const c = await fresh()
+ c.transactions.reverse()
+ expect(() => assertRheaDexSafe(c, ME)).toThrow(/last transaction/)
+
+ const d = await fresh()
+ ;(d.transactions[0] as NearTransaction).receiverId = USDT_NEAR
+ expect(() => assertRheaDexSafe(d, ME)).toThrow(/wrong token/)
+
+ const e = await fresh()
+ ;(e.transactions[1] as NearTransaction).receiverId = THEM
+ expect(() => assertRheaDexSafe(e, ME)).toThrow(/not a listed token/)
+
+ const f = await fresh()
+ ;(lastCall(f.transactions[1]) as { deposit: string }).deposit = '2'
+ expect(() => assertRheaDexSafe(f, ME)).toThrow(/exactly 1 yoctoNEAR/)
+
+ const g = await fresh()
+ ;(lastCall(g.transactions[1]) as { gas: string }).gas = (
+ BigInt(301) *
+ BigInt(10) ** BigInt(12)
+ ).toString()
+ expect(() => assertRheaDexSafe(g, ME)).toThrow(/more gas/)
+ })
+
+ it('refuses a plain transfer and another chain', () => {
+ expect(() =>
+ assertRheaDexSafe(
+ {
+ family: 'near',
+ transactions: [
+ {
+ receiverId: USDC_NEAR,
+ actions: [
+ {
+ type: 'FunctionCall',
+ params: {
+ methodName: 'ft_transfer',
+ args: { receiver_id: THEM, amount: '1' },
+ gas: '1',
+ deposit: '1',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ME
+ )
+ ).toThrow(ChainActionRefused)
+ expect(() => assertRheaDexSafe({ family: 'sui', transaction: 'x' }, ME)).toThrow(
+ /not a NEAR transaction/
+ )
+ })
+})
+
+describe('getting tokens back out of Rhea DEX', () => {
+ const withdraw = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'withdraw',
+ amount: '5',
+ tokenIn: 'USDC',
+ ...over,
+ })
+
+ it('shows what is held there, what is waiting and wNEAR in the wallet', async () => {
+ rpc.views[`${RHEA_DEX}.get_deposits`] = () => ({ [USDC_NEAR]: dollars(5), [THEM]: '9' })
+ rpc.views[`${RHEA_DEX}.list_lostfound_tokens`] = () => ({ [USDT_NEAR]: dollars(2) })
+ rpc.views[`${WRAP_NEAR}.ft_balance_of`] = () => yocto(3)
+ await expect(rheaDexVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'NEAR', amount: '3' },
+ { slot: 'supplied', symbol: 'USDC', amount: '5' },
+ { slot: 'claimable', symbol: 'USDT', amount: '2' },
+ ])
+ })
+
+ it('throws, rather than reading as empty, when it can not read', async () => {
+ rpc.down = true
+ await expect(rheaDexVenue.positions?.(ME)).rejects.toThrow()
+ })
+
+ it('takes a set amount, or all of it with the contract zero', async () => {
+ rpc.views[`${RHEA_DEX}.get_deposits`] = () => ({ [USDC_NEAR]: dollars(5) })
+ const some = await rheaDexVenue.prepare(withdraw({ amount: '2' }), ME)
+ expect(() => rheaDexVenue.assertSafe(some.signing, ME)).not.toThrow()
+ expect(lastCall(txs(some)[0]).methodName).toBe('withdraw')
+ expect(lastCall(txs(some)[0]).args).toEqual({ token_id: USDC_NEAR, amount: dollars(2) })
+ expect(lastCall(txs(some)[0]).deposit).toBe('1')
+
+ const all = await rheaDexVenue.prepare(withdraw({ wholePosition: true }), ME)
+ expect(lastCall(txs(all)[0]).args).toEqual({ token_id: USDC_NEAR, amount: '0' })
+ })
+
+ it('registers you first if the token could not reach you', async () => {
+ rpc.views[`${RHEA_DEX}.get_deposits`] = () => ({ [USDC_NEAR]: dollars(5) })
+ rpc.views[`${USDC_NEAR}.storage_balance_of`] = () => null
+ const out = await rheaDexVenue.prepare(withdraw(), ME)
+ expect(() => rheaDexVenue.assertSafe(out.signing, ME)).not.toThrow()
+ expect(txs(out).map((t) => methods(t)[0])).toEqual(['storage_deposit', 'withdraw'])
+ })
+
+ it('hands NEAR back as NEAR, from Rhea first and then wNEAR in the wallet', async () => {
+ rpc.views[`${RHEA_DEX}.get_deposits`] = () => ({ [WRAP_NEAR]: yocto(1) })
+ rpc.views[`${WRAP_NEAR}.ft_balance_of`] = () => yocto(2)
+ const out = await rheaDexVenue.prepare(withdraw({ tokenIn: 'NEAR', amount: '2.5' }), ME)
+ expect(() => rheaDexVenue.assertSafe(out.signing, ME)).not.toThrow()
+ const [atRhea, inWallet] = txs(out)
+ expect(lastCall(atRhea).args).toEqual({
+ token_id: WRAP_NEAR,
+ amount: yocto(1),
+ skip_unwrap_near: false,
+ })
+ expect(inWallet?.receiverId).toBe(WRAP_NEAR)
+ expect(lastCall(inWallet).methodName).toBe('near_withdraw')
+ expect(lastCall(inWallet).args).toEqual({
+ amount: (ONE_NEAR + ONE_NEAR / BigInt(2)).toString(),
+ })
+ })
+
+ it('unwraps what a stopped swap handed back', async () => {
+ rpc.views[`${WRAP_NEAR}.ft_balance_of`] = () => yocto(2)
+ const out = await rheaDexVenue.prepare(
+ withdraw({ tokenIn: 'NEAR', amount: '2', wholePosition: true }),
+ ME
+ )
+ expect(txs(out)).toHaveLength(1)
+ expect(lastCall(txs(out)[0]).args).toEqual({ amount: yocto(2) })
+ expect(out.summary).toMatch(/wNEAR in your wallet back into NEAR/)
+ })
+
+ it('refuses more than there is, and nothing at all', async () => {
+ await expect(rheaDexVenue.prepare(withdraw(), ME)).rejects.toThrow(/no USDC to take back/)
+ rpc.views[`${RHEA_DEX}.get_deposits`] = () => ({ [USDC_NEAR]: dollars(1) })
+ await expect(rheaDexVenue.prepare(withdraw(), ME)).rejects.toThrow(/less than 5/)
+ })
+
+ it('claims what waits under your name, and says so when nothing does', async () => {
+ const claim: ChainActionRequest = { kind: 'claim', amount: 'all', tokenIn: 'NEAR' }
+ await expect(rheaDexVenue.prepare(claim, ME)).rejects.toThrow(/Nothing in NEAR is waiting/)
+ rpc.views[`${RHEA_DEX}.list_lostfound_tokens`] = () => ({ [WRAP_NEAR]: yocto(1) })
+ const out = await rheaDexVenue.prepare(claim, ME)
+ expect(() => rheaDexVenue.assertSafe(out.signing, ME)).not.toThrow()
+ expect(txs(out).map((t) => methods(t)[0])).toEqual(['claim_lostfound', 'near_withdraw'])
+ })
+
+ it.each<[string, string, Record, string]>([
+ ['another method', 'storage_withdraw', { token_id: USDC_NEAR, amount: '1' }, '1'],
+ ['an unlisted token', 'withdraw', { token_id: 'evil.near', amount: '1' }, '1'],
+ ['an extra argument', 'withdraw', { token_id: USDC_NEAR, amount: '1', to: THEM }, '1'],
+ ['no yocto', 'withdraw', { token_id: USDC_NEAR, amount: '1' }, '0'],
+ [
+ 'wNEAR left wrapped',
+ 'withdraw',
+ { token_id: WRAP_NEAR, amount: '1', skip_unwrap_near: true },
+ '1',
+ ],
+ ])('refuses %s on the way out', (_name, methodName, args, deposit) => {
+ expect(() =>
+ assertRheaDexSafe(
+ {
+ family: 'near',
+ transactions: [
+ {
+ receiverId: RHEA_DEX,
+ actions: [
+ { type: 'FunctionCall', params: { methodName, args, gas: '100', deposit } },
+ ],
+ },
+ ],
+ },
+ ME
+ )
+ ).toThrow(ChainActionRefused)
+ })
+})
+
+interface FakeTx {
+ signer?: string
+ receiver?: string
+ method?: string
+ args?: Record
+ returned?: string
+ failed?: boolean
+ receipts?: { executor: string; failed?: boolean; logs?: string[] }[]
+}
+
+function fakeTx(tx: FakeTx): unknown {
+ const args = Buffer.from(JSON.stringify(tx.args ?? {})).toString('base64')
+ return {
+ status: tx.failed
+ ? { Failure: { ActionError: {} } }
+ : { SuccessValue: Buffer.from(tx.returned ?? '').toString('base64') },
+ transaction: {
+ signer_id: tx.signer ?? ME,
+ receiver_id: tx.receiver ?? RHEA_LEND,
+ actions: [{ FunctionCall: { method_name: tx.method ?? 'execute', args, deposit: '1' } }],
+ },
+ receipts_outcome: (tx.receipts ?? []).map((r) => ({
+ outcome: {
+ executor_id: r.executor,
+ logs: r.logs ?? [],
+ status: r.failed ? { Failure: {} } : { SuccessValue: '' },
+ },
+ })),
+ }
+}
+
+const signedNear = (hashes: string[] = [HASH]) => ({
+ account: ME,
+ venueId: 'x',
+ signed: { family: 'near' as const, hashes },
+})
+
+describe('confirming a Rhea DEX swap', () => {
+ const swapTx = (over: FakeTx = {}): FakeTx => ({
+ receiver: WRAP_NEAR,
+ method: 'ft_transfer_call',
+ args: {
+ receiver_id: RHEA_DEX,
+ amount: yocto(1),
+ msg: JSON.stringify({ actions: [{ token_out: USDC_NEAR }] }),
+ },
+ returned: JSON.stringify(yocto(1)),
+ ...over,
+ })
+
+ it('reports a swap that ran and delivered', async () => {
+ rpc.tx = () => fakeTx(swapTx())
+ await expect(confirmRheaDex(signedNear())).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('never calls a refunded swap done, though the transaction succeeded', async () => {
+ rpc.tx = () => fakeTx(swapTx({ returned: '"0"' }))
+ const out = await confirmRheaDex(signedNear())
+ expect(out.ok).toBe(false)
+ expect(out).not.toHaveProperty('pending')
+ expect((out as { error: string }).error).toMatch(/came back as wNEAR/)
+ })
+
+ it('says where the money is when delivery failed', async () => {
+ rpc.tx = () => fakeTx(swapTx({ receipts: [{ executor: USDC_NEAR, failed: true }] }))
+ const out = await confirmRheaDex(signedNear())
+ expect(out).toMatchObject({ ok: false, pending: true })
+ expect((out as { error: string }).error).toMatch(/withdraw USDC on Rhea DEX/)
+ })
+
+ it('ignores a failed price refresh, which does not touch the swap', async () => {
+ rpc.tx = () =>
+ fakeTx(swapTx({ receipts: [{ executor: 'aggregate-oracle.near', failed: true }] }))
+ await expect(confirmRheaDex(signedNear())).resolves.toMatchObject({ ok: true })
+ })
+
+ it('says nothing moved when the transaction itself failed', async () => {
+ rpc.tx = () => fakeTx(swapTx({ failed: true }))
+ const out = await confirmRheaDex(signedNear())
+ expect(out.ok).toBe(false)
+ expect(out).not.toHaveProperty('pending')
+ })
+
+ it('stays pending on a swap whose result can not be read', async () => {
+ rpc.tx = () => fakeTx(swapTx({ returned: '' }))
+ await expect(confirmRheaDex(signedNear())).resolves.toMatchObject({ ok: false, pending: true })
+ })
+
+ it.each<[string, FakeTx]>([
+ ['someone else signed', swapTx({ signer: THEM })],
+ ['it ran on another contract', swapTx({ receiver: 'evil.near' })],
+ ['it paid someone else', swapTx({ args: { receiver_id: THEM, amount: '1', msg: '{}' } })],
+ ['it is some other call', { receiver: WRAP_NEAR, method: 'ft_transfer', args: {} }],
+ ])('will not report a hash where %s', async (_name, tx) => {
+ rpc.tx = () => fakeTx(tx)
+ await expect(confirmRheaDex(signedNear())).resolves.toMatchObject({ ok: false, pending: true })
+ })
+
+ it('does not call a registration alone a swap', async () => {
+ rpc.tx = () => fakeTx({ receiver: USDC_NEAR, method: 'storage_deposit', args: {} })
+ await expect(confirmRheaDex(signedNear())).resolves.toMatchObject({ ok: false, pending: true })
+ })
+
+ it('carries on past a registration to the swap', async () => {
+ rpc.tx = (hash) =>
+ fakeTx(
+ hash === HASH
+ ? { receiver: USDC_NEAR, method: 'storage_deposit', args: {}, returned: '{}' }
+ : swapTx()
+ )
+ await expect(confirmRheaDex(signedNear([HASH, HASH2]))).resolves.toMatchObject({
+ ok: true,
+ hash: HASH2,
+ })
+ })
+
+ it('reports a withdrawal that could not reach the wallet', async () => {
+ rpc.tx = () => fakeTx({ receiver: RHEA_DEX, method: 'withdraw', returned: '"0"' })
+ const out = await confirmRheaDex(signedNear())
+ expect(out.ok).toBe(false)
+ expect((out as { error: string }).error).toMatch(/still held under your name/)
+ })
+
+ it('refuses a malformed hash, keeping the money latched', async () => {
+ await expect(confirmRheaDex(signedNear(['not a hash']))).resolves.toMatchObject({
+ ok: false,
+ pending: true,
+ })
+ })
+}, 30_000)
+
+function lendAccount(over: {
+ plain?: [string, string][]
+ collateral?: [string, string][]
+ borrowed?: [string, string][]
+ locked?: boolean
+}): unknown {
+ const lines = (entries: [string, string][] = []) =>
+ entries.map(([token_id, balance]) => ({ token_id, balance, shares: balance, apr: '0' }))
+ return {
+ account_id: ME,
+ supplied: lines(over.plain),
+ positions: {
+ REGULAR: { collateral: lines(over.collateral), borrowed: lines(over.borrowed) },
+ },
+ is_locked: over.locked ?? false,
+ }
+}
+
+const lend = (kind: ChainActionRequest['kind'], amount: string, tokenIn = 'USDC', more = {}) =>
+ ({ kind, amount, tokenIn, ...more }) as ChainActionRequest
+
+function actionsOf(tx: NearTransaction | undefined): unknown {
+ return lastCall(tx).args['actions']
+}
+
+describe('supplying to Rhea Lend', () => {
+ it('sends the token with an empty message, to the pinned contract', async () => {
+ const prepared = await rheaLendVenue.prepare(lend('supply', '10'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [tx] = txs(prepared)
+ expect(txs(prepared)).toHaveLength(1)
+ expect(tx?.receiverId).toBe(USDC_NEAR)
+ expect(lastCall(tx).args).toEqual({ receiver_id: RHEA_LEND, amount: dollars(10), msg: '' })
+ expect(prepared.apy?.basePercent).toBeCloseTo(9.85)
+ expect(prepared.notes.join(' ')).toMatch(/80% of this market is lent out/)
+ })
+
+ it('pays for the record first on a first use', async () => {
+ rpc.views[`${RHEA_LEND}.storage_balance_of`] = () => null
+ const prepared = await rheaLendVenue.prepare(lend('supply', '10'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [first] = txs(prepared)
+ expect(first?.receiverId).toBe(RHEA_LEND)
+ expect(first?.actions[0]?.params.deposit).toBe(RHEA_LEND_STORAGE_YOCTO)
+ expect(prepared.notes.join(' ')).toMatch(/0.1 NEAR/)
+ })
+
+ it('wraps NEAR in the same transaction', async () => {
+ const prepared = await rheaLendVenue.prepare(lend('supply', '2', 'NEAR'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(methods(txs(prepared)[0])).toEqual(['near_deposit', 'ft_transfer_call'])
+ expect(txs(prepared)[0]?.actions[0]?.params.deposit).toBe(yocto(2))
+ })
+
+ it('refuses more than the wallet holds, and a market that is closed or full', async () => {
+ await expect(rheaLendVenue.prepare(lend('supply', '5000'), ME)).rejects.toThrow(/You hold 1000/)
+ const usdc = assets[USDC_NEAR] as { config: Record }
+ usdc.config['supplied_limit'] = '1'
+ await expect(rheaLendVenue.prepare(lend('supply', '10'), ME)).rejects.toThrow(/full/)
+ usdc.config['can_deposit'] = false
+ await expect(rheaLendVenue.prepare(lend('supply', '10'), ME)).rejects.toThrow(/isn't taking/)
+ })
+
+ it('refuses while the contract is paused, and a token it does not take', async () => {
+ await expect(rheaLendVenue.prepare(lend('supply', '1', 'DOGE'), ME)).rejects.toThrow(
+ /takes NEAR, USDC or USDT/
+ )
+ rpc.views[`${RHEA_LEND}.get_config`] = () => ({ contract_mode: 'Paused' })
+ await expect(rheaLendVenue.prepare(lend('supply', '10'), ME)).rejects.toThrow(/paused/)
+ })
+
+ it('gives its rate as a quote, and nothing else', async () => {
+ const quoted = await rheaLendVenue.quote?.(lend('supply', '10'), ME)
+ expect(quoted?.apyPercent).toBeCloseTo(9.85)
+ await expect(rheaLendVenue.quote?.(lend('borrow', '10'), ME)).resolves.toEqual({})
+ })
+})
+
+describe('borrowing on Rhea Lend', () => {
+ const backed = (usdc = 100): unknown => lendAccount({ collateral: [[USDC_NEAR, inner18(usdc)]] })
+
+ it('borrows within what the collateral backs, and takes it out to the signer', async () => {
+ account = backed()
+ const prepared = await rheaLendVenue.prepare(lend('borrow', '70', 'USDT'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [tx] = txs(prepared)
+ expect(txs(prepared)).toHaveLength(1)
+ expect(tx?.receiverId).toBe(RHEA_LEND)
+ expect(lastCall(tx).methodName).toBe('execute_with_pyth')
+ expect(lastCall(tx).deposit).toBe('1')
+ expect(actionsOf(tx)).toEqual([
+ { Borrow: { token_id: USDT_NEAR, amount: inner18(70) } },
+ { Withdraw: { token_id: USDT_NEAR, max_amount: inner18(70) } },
+ ])
+ expect(prepared.receive).toEqual({ amount: '70', symbol: 'USDT', approximate: false })
+ })
+
+ it('states liquidation plainly, with the real margin', async () => {
+ account = backed()
+ const prepared = await rheaLendVenue.prepare(lend('borrow', '70', 'USDT'), ME)
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/You can lose your collateral/)
+ expect(notes).toMatch(/129% of the level/)
+ expect(notes).toMatch(/falls about 22\.\d% against what you owe/)
+ expect(notes).toMatch(/anyone can liquidate you/)
+ expect(notes).toMatch(/about 15.24% a year/)
+ })
+
+ it('refuses a borrow the position can not safely support, and says what it could', async () => {
+ account = backed()
+ await expect(rheaLendVenue.prepare(lend('borrow', '80', 'USDT'), ME)).rejects.toThrow(
+ /can't safely back 80 USDT. You could borrow about 75\.2/
+ )
+ })
+
+ it('refuses at the contract limit too, where liquidation could already begin', async () => {
+ account = backed()
+ await expect(rheaLendVenue.prepare(lend('borrow', '90', 'USDT'), ME)).rejects.toThrow(
+ /can't safely back/
+ )
+ })
+
+ it('counts what is already owed', async () => {
+ account = lendAccount({
+ collateral: [[USDC_NEAR, inner18(100)]],
+ borrowed: [[USDT_NEAR, inner18(70)]],
+ })
+ await expect(rheaLendVenue.prepare(lend('borrow', '10', 'USDT'), ME)).rejects.toThrow(
+ /about 5\.2/
+ )
+ })
+
+ it('refuses with nothing supplied', async () => {
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(
+ /Nothing of yours backs a loan/
+ )
+ })
+
+ it('moves what is supplied to back the loan, before the borrow and in its own transaction', async () => {
+ account = lendAccount({
+ plain: [
+ [USDC_NEAR, inner18(100)],
+ ['dai.near', inner18(5000)],
+ ],
+ })
+ const prepared = await rheaLendVenue.prepare(lend('borrow', '50', 'USDT'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [move, borrow] = txs(prepared)
+ expect(actionsOf(move)).toEqual([{ IncreaseCollateral: { token_id: USDC_NEAR } }])
+ expect(lastCall(borrow).methodName).toBe('execute_with_pyth')
+ expect(prepared.notes.join(' ')).toMatch(/All your supplied USDC now backs this loan/)
+ })
+
+ it('does not count a supply that can not back a loan', async () => {
+ account = lendAccount({ plain: [[USDC_NEAR, inner18(100)]] })
+ const usdc = assets[USDC_NEAR] as { config: Record }
+ usdc.config['can_use_as_collateral'] = false
+ await expect(rheaLendVenue.prepare(lend('borrow', '50', 'USDT'), ME)).rejects.toThrow(
+ /Nothing of yours backs a loan/
+ )
+ })
+
+ it('values NEAR collateral by its own price and ratio', async () => {
+ account = lendAccount({ collateral: [[WRAP_NEAR, yocto(100)]] })
+ await expect(rheaLendVenue.prepare(lend('borrow', '230', 'USDC'), ME)).resolves.toBeDefined()
+ await expect(rheaLendVenue.prepare(lend('borrow', '260', 'USDC'), ME)).rejects.toThrow(
+ /You could borrow about 237\.5/
+ )
+ })
+
+ it('borrows NEAR as NEAR: registers, takes wNEAR, then unwraps', async () => {
+ account = backed()
+ rpc.views[`${WRAP_NEAR}.storage_balance_of`] = () => null
+ const prepared = await rheaLendVenue.prepare(lend('borrow', '5', 'NEAR'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(txs(prepared).map((t) => methods(t)[0])).toEqual([
+ 'storage_deposit',
+ 'execute_with_pyth',
+ 'near_withdraw',
+ ])
+ expect(lastCall(txs(prepared)[2]).args).toEqual({
+ amount: (BigInt(5) * ONE_NEAR - BigInt(10)).toString(),
+ })
+ })
+
+ it('refuses without prices, or more than is free to lend, or under the minimum', async () => {
+ account = backed()
+ await expect(rheaLendVenue.prepare(lend('borrow', '0.000000001', 'USDT'), ME)).rejects.toThrow()
+ const usdt = assets[USDT_NEAR] as { config: Record; supplied: unknown }
+ usdt.config['min_borrowed_amount'] = inner18(5)
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(
+ /lends at least 5 USDT/
+ )
+ usdt.config['min_borrowed_amount'] = '1'
+ usdt.config['borrowed_limit'] = '1'
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(/limit/)
+ usdt.config['borrowed_limit'] = null
+ usdt.supplied = { balance: inner18(800_001) }
+ await expect(rheaLendVenue.prepare(lend('borrow', '2', 'USDT'), ME)).rejects.toThrow(
+ /only about 1 USDT free/
+ )
+ usdt.supplied = { balance: inner18(1_000_000) }
+ rpc.views['aggregate-oracle.near.get_price_no_older_than'] = () => null
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(
+ /prices couldn't be read/
+ )
+ })
+
+ it('refuses when lending is off, the account is locked, or prices come from elsewhere', async () => {
+ account = lendAccount({ collateral: [[USDC_NEAR, inner18(100)]], locked: true })
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(/locked/)
+ account = backed()
+ const usdt = assets[USDT_NEAR] as { config: Record }
+ usdt.config['can_borrow'] = false
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(
+ /isn't lending/
+ )
+ rpc.views[`${RHEA_LEND}.get_config`] = () => ({
+ contract_mode: 'Running',
+ enable_pyth_oracle: true,
+ pyth_oracle_account_id: 'evil-oracle.near',
+ })
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(
+ /source this app checks/
+ )
+ })
+
+ it('stops if the contract changes how it counts a token', async () => {
+ account = backed()
+ const usdt = assets[USDT_NEAR] as { config: Record }
+ usdt.config['extra_decimals'] = 0
+ await expect(rheaLendVenue.prepare(lend('borrow', '1', 'USDT'), ME)).rejects.toThrow(
+ /could not be read/
+ )
+ })
+})
+
+describe('withdrawing from Rhea Lend', () => {
+ it('takes plain supply with no prices', async () => {
+ account = lendAccount({ plain: [[USDC_NEAR, inner18(50)]] })
+ const prepared = await rheaLendVenue.prepare(lend('withdraw', '20'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(lastCall(txs(prepared)[0]).methodName).toBe('execute')
+ expect(actionsOf(txs(prepared)[0])).toEqual([
+ { Withdraw: { token_id: USDC_NEAR, max_amount: inner18(20) } },
+ ])
+ expect(prepared.receive?.amount).toBe('20')
+ })
+
+ it('empties plain supply exactly, naming no amount', async () => {
+ account = lendAccount({ plain: [[USDC_NEAR, inner18(50)]] })
+ const prepared = await rheaLendVenue.prepare(
+ lend('withdraw', '50', 'USDC', { wholePosition: true }),
+ ME
+ )
+ expect(lastCall(txs(prepared)[0]).methodName).toBe('execute')
+ expect(actionsOf(txs(prepared)[0])).toEqual([{ Withdraw: { token_id: USDC_NEAR } }])
+ })
+
+ it('empties supply and collateral exactly, with prices', async () => {
+ account = lendAccount({
+ plain: [[USDC_NEAR, inner18(10)]],
+ collateral: [[USDC_NEAR, inner18(40)]],
+ })
+ const prepared = await rheaLendVenue.prepare(
+ lend('withdraw', '50', 'USDC', { wholePosition: true }),
+ ME
+ )
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(lastCall(txs(prepared)[0]).methodName).toBe('execute_with_pyth')
+ expect(actionsOf(txs(prepared)[0])).toEqual([
+ { DecreaseCollateral: { token_id: USDC_NEAR } },
+ { Withdraw: { token_id: USDC_NEAR } },
+ ])
+ })
+
+ it('releases only the collateral it needs beyond plain supply', async () => {
+ account = lendAccount({
+ plain: [[USDC_NEAR, inner18(10)]],
+ collateral: [[USDC_NEAR, inner18(40)]],
+ })
+ const prepared = await rheaLendVenue.prepare(lend('withdraw', '25'), ME)
+ expect(actionsOf(txs(prepared)[0])).toEqual([
+ { DecreaseCollateral: { token_id: USDC_NEAR, amount: inner18(15) } },
+ { Withdraw: { token_id: USDC_NEAR, max_amount: inner18(25) } },
+ ])
+ })
+
+ it('refuses what would leave a loan without enough behind it, and says what could go', async () => {
+ account = lendAccount({
+ collateral: [[USDC_NEAR, inner18(100)]],
+ borrowed: [[USDT_NEAR, inner18(50)]],
+ })
+ await expect(rheaLendVenue.prepare(lend('withdraw', '40'), ME)).rejects.toThrow(
+ /without enough behind it. You could take out about 33\.5/
+ )
+ await expect(
+ rheaLendVenue.prepare(lend('withdraw', '100', 'USDC', { wholePosition: true }), ME)
+ ).rejects.toThrow(/without enough behind it/)
+ })
+
+ it('allows what leaves a loan safe, and states the risk that remains', async () => {
+ account = lendAccount({
+ collateral: [[USDC_NEAR, inner18(100)]],
+ borrowed: [[USDT_NEAR, inner18(50)]],
+ })
+ const prepared = await rheaLendVenue.prepare(lend('withdraw', '30'), ME)
+ expect(prepared.notes.join(' ')).toMatch(/You can lose your collateral/)
+ })
+
+ it('says to repay first when everything is backing the loan', async () => {
+ account = lendAccount({
+ collateral: [[USDC_NEAR, inner18(100)]],
+ borrowed: [[USDT_NEAR, inner18(75)]],
+ })
+ await expect(rheaLendVenue.prepare(lend('withdraw', '10'), ME)).rejects.toThrow(/repay first/)
+ })
+
+ it('takes plain supply past a loan, which it does not back', async () => {
+ account = lendAccount({
+ plain: [[USDC_NEAR, inner18(10)]],
+ collateral: [[USDT_NEAR, inner18(100)]],
+ borrowed: [[USDT_NEAR, inner18(75)]],
+ })
+ const prepared = await rheaLendVenue.prepare(lend('withdraw', '10'), ME)
+ expect(lastCall(txs(prepared)[0]).methodName).toBe('execute')
+ })
+
+ it('refuses more than is free to leave, and says how much is', async () => {
+ account = lendAccount({ plain: [[USDC_NEAR, inner18(50)]] })
+ const usdc = assets[USDC_NEAR] as { supplied: unknown }
+ usdc.supplied = { balance: inner18(800_020) }
+ await expect(rheaLendVenue.prepare(lend('withdraw', '30'), ME)).rejects.toThrow(
+ /Only about 20 USDC can leave Rhea Lend right now, because borrowers hold the rest/
+ )
+ })
+
+ it('refuses more than you have, nothing, a stopped market and a locked account', async () => {
+ await expect(rheaLendVenue.prepare(lend('withdraw', '1'), ME)).rejects.toThrow(/no USDC/)
+ account = lendAccount({ plain: [[USDC_NEAR, inner18(1)]] })
+ await expect(rheaLendVenue.prepare(lend('withdraw', '5'), ME)).rejects.toThrow(/less than 5/)
+ const usdc = assets[USDC_NEAR] as { config: Record }
+ usdc.config['can_withdraw'] = false
+ await expect(rheaLendVenue.prepare(lend('withdraw', '1'), ME)).rejects.toThrow(/stopped/)
+ account = lendAccount({ plain: [[USDC_NEAR, inner18(1)]], locked: true })
+ await expect(rheaLendVenue.prepare(lend('withdraw', '1'), ME)).rejects.toThrow(/locked/)
+ })
+
+ it('hands NEAR back as NEAR, and registers first if it could not reach you', async () => {
+ account = lendAccount({ plain: [[WRAP_NEAR, yocto(3)]] })
+ rpc.views[`${WRAP_NEAR}.storage_balance_of`] = () => null
+ const prepared = await rheaLendVenue.prepare(lend('withdraw', '3', 'NEAR'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(txs(prepared).map((t) => methods(t)[0])).toEqual([
+ 'storage_deposit',
+ 'execute',
+ 'near_withdraw',
+ ])
+ })
+})
+
+describe('repaying on Rhea Lend', () => {
+ const owing = (): unknown =>
+ lendAccount({
+ collateral: [[USDC_NEAR, inner18(100)]],
+ borrowed: [[USDT_NEAR, inner18(50)]],
+ })
+ const repayment = (amount: string): string =>
+ JSON.stringify({
+ Execute: {
+ actions: [{ Repay: { token_id: USDT_NEAR, max_amount: `${amount}000000000000` } }],
+ },
+ })
+
+ it('repays exactly what is sent, and nothing else held there', async () => {
+ account = owing()
+ const prepared = await rheaLendVenue.prepare(lend('repay', '20', 'USDT'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const [tx] = txs(prepared)
+ expect(tx?.receiverId).toBe(USDT_NEAR)
+ expect(lastCall(tx).args).toEqual({
+ receiver_id: RHEA_LEND,
+ amount: dollars(20),
+ msg: repayment(dollars(20)),
+ })
+ })
+
+ it('clears a whole loan by sending a little over, and says where the rest goes', async () => {
+ account = owing()
+ const prepared = await rheaLendVenue.prepare(
+ lend('repay', '50', 'USDT', { wholePosition: true }),
+ ME
+ )
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(lastCall(txs(prepared)[0]).args['amount']).toBe('50050001')
+ expect(lastCall(txs(prepared)[0]).args['msg']).toBe(repayment('50050001'))
+ expect(prepared.pay).toEqual({ amount: '50.050001', symbol: 'USDT' })
+ expect(prepared.notes.join(' ')).toMatch(/stays as your supply/)
+ })
+
+ it('rounds what is owed up, so no fraction of a unit stays owed', async () => {
+ account = lendAccount({ borrowed: [[USDT_NEAR, '50000000000000000001']] })
+ const prepared = await rheaLendVenue.prepare(
+ lend('repay', '50', 'USDT', { wholePosition: true }),
+ ME
+ )
+ expect(lastCall(txs(prepared)[0]).args['amount']).toBe('50050002')
+ })
+
+ it('sends what the wallet holds if that covers the debt but not the margin', async () => {
+ account = owing()
+ rpc.views[`${USDT_NEAR}.ft_balance_of`] = () => dollars(50)
+ const prepared = await rheaLendVenue.prepare(
+ lend('repay', '50', 'USDT', { wholePosition: true }),
+ ME
+ )
+ expect(lastCall(txs(prepared)[0]).args['amount']).toBe(dollars(50))
+ })
+
+ it('refuses paying more than is owed, less than is held, and a loan that is not there', async () => {
+ await expect(rheaLendVenue.prepare(lend('repay', '1', 'USDT'), ME)).rejects.toThrow(
+ /don't owe any USDT/
+ )
+ account = owing()
+ await expect(rheaLendVenue.prepare(lend('repay', '60', 'USDT'), ME)).rejects.toThrow(
+ /repay all USDT/
+ )
+ rpc.views[`${USDT_NEAR}.ft_balance_of`] = () => dollars(5)
+ await expect(rheaLendVenue.prepare(lend('repay', '20', 'USDT'), ME)).rejects.toThrow(
+ /You hold 5 USDT/
+ )
+ await expect(
+ rheaLendVenue.prepare(lend('repay', '50', 'USDT', { wholePosition: true }), ME)
+ ).rejects.toThrow(/You hold 5 USDT/)
+ })
+
+ it('wraps NEAR for a NEAR loan in the same transaction', async () => {
+ account = lendAccount({ borrowed: [[WRAP_NEAR, yocto(2)]] })
+ const prepared = await rheaLendVenue.prepare(lend('repay', '1', 'NEAR'), ME)
+ expect(() => rheaLendVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ expect(methods(txs(prepared)[0])).toEqual(['near_deposit', 'ft_transfer_call'])
+ })
+})
+
+describe('reading a Rhea Lend position', () => {
+ it('adds plain supply and collateral, shows debt, and leaves out unlisted tokens', async () => {
+ account = lendAccount({
+ plain: [
+ [USDC_NEAR, inner18(10)],
+ ['dai.near', inner18(5)],
+ ],
+ collateral: [
+ [USDC_NEAR, inner18(40)],
+ [WRAP_NEAR, yocto(2)],
+ ],
+ borrowed: [[USDT_NEAR, '12345678000000000000']],
+ })
+ await expect(rheaLendVenue.positions?.(ME)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'NEAR', amount: '2' },
+ { slot: 'supplied', symbol: 'USDC', amount: '50' },
+ { slot: 'borrowed', symbol: 'USDT', amount: '12.345678' },
+ ])
+ })
+
+ it('is empty for an account with nothing there', async () => {
+ await expect(rheaLendVenue.positions?.(ME)).resolves.toEqual([])
+ })
+
+ it('throws, rather than reading as empty, when it can not read', async () => {
+ rpc.down = true
+ await expect(rheaLendVenue.positions?.(ME)).rejects.toThrow()
+ })
+})
+
+describe('an exit through the shared routing', () => {
+ const chain: ChainModule = {
+ ...nearModule,
+ venues: [rheaLendVenue, rheaDexVenue],
+ defaultVenue: {},
+ }
+
+ it('goes to whichever venue holds it, and reaches it whole', async () => {
+ account = lendAccount({ collateral: [[USDC_NEAR, inner18(40)]] })
+ const out = await prepareChainAction(chain, lend('withdraw', 'all'), ME)
+ expect(out.venueId).toBe('rhea-lend')
+ expect(actionsOf(txs(out)[0])).toEqual([
+ { DecreaseCollateral: { token_id: USDC_NEAR } },
+ { Withdraw: { token_id: USDC_NEAR } },
+ ])
+ })
+
+ it('finds tokens stranded at Rhea DEX the same way', async () => {
+ rpc.views[`${RHEA_DEX}.get_deposits`] = () => ({ [USDC_NEAR]: dollars(7) })
+ const out = await prepareChainAction(chain, lend('withdraw', 'all'), ME)
+ expect(out.venueId).toBe('rhea-dex')
+ expect(lastCall(txs(out)[0]).args).toEqual({ token_id: USDC_NEAR, amount: '0' })
+ })
+
+ it('pays off a whole loan from the word "all"', async () => {
+ account = lendAccount({ borrowed: [[USDT_NEAR, inner18(10)]] })
+ const out = await prepareChainAction(
+ { ...chain, venues: [rheaLendVenue] },
+ lend('repay', 'all', 'USDT'),
+ ME
+ )
+ expect(lastCall(txs(out)[0]).args['amount']).toBe('10010001')
+ })
+})
+
+describe('the Rhea Lend policy', () => {
+ const call = (
+ methodName: string,
+ args: Record,
+ deposit = '1',
+ gas = '100'
+ ): NearTransaction['actions'][number] => ({
+ type: 'FunctionCall',
+ params: { methodName, args, gas, deposit },
+ })
+ const payload = (...transactions: NearTransaction[]): SigningPayload => ({
+ family: 'near',
+ transactions,
+ })
+ const exec = (actions: unknown[], method = 'execute_with_pyth'): NearTransaction => ({
+ receiverId: RHEA_LEND,
+ actions: [call(method, { actions })],
+ })
+ const borrow = { Borrow: { token_id: USDC_NEAR, amount: '5' } }
+ const take = { Withdraw: { token_id: USDC_NEAR, max_amount: '5' } }
+
+ it.each<[string, SigningPayload]>([
+ ['liquidating someone', payload(exec([{ Liquidate: { account_id: THEM } }, take]))],
+ ['closing someone out', payload(exec([{ ForceClose: { account_id: THEM } }, take]))],
+ [
+ 'a callback withdrawal',
+ payload(exec([{ ClientEchoWithdraw: { client_echo: 'x', asset_amount: {} } }], 'execute')),
+ ],
+ ['a position on LP tokens', payload(exec([{ PositionBorrow: {} }, take]))],
+ ['repaying out of supply', payload(exec([{ Repay: { token_id: USDC_NEAR } }], 'execute'))],
+ ['a borrow without prices', payload(exec([borrow, take], 'execute'))],
+ ['a borrow left inside', payload(exec([borrow]))],
+ [
+ 'taking out more than borrowed',
+ payload(exec([borrow, { Withdraw: { token_id: USDC_NEAR, max_amount: '6' } }])),
+ ],
+ [
+ 'taking out another token',
+ payload(exec([borrow, { Withdraw: { token_id: USDT_NEAR, max_amount: '5' } }])),
+ ],
+ ['an unlisted token', payload(exec([{ Withdraw: { token_id: 'evil.near' } }], 'execute'))],
+ [
+ 'a recipient on a step',
+ payload(exec([{ Withdraw: { token_id: USDC_NEAR, recipient: THEM } }], 'execute')),
+ ],
+ [
+ 'releasing one token and taking another',
+ payload(
+ exec([
+ { DecreaseCollateral: { token_id: USDT_NEAR } },
+ { Withdraw: { token_id: USDC_NEAR } },
+ ])
+ ),
+ ],
+ [
+ 'a withdrawal that can name who is paid',
+ payload({
+ receiverId: RHEA_LEND,
+ actions: [
+ call('simple_withdraw', {
+ token_id: USDC_NEAR,
+ amount_with_inner_decimal: '1',
+ recipient_id: THEM,
+ }),
+ ],
+ }),
+ ],
+ [
+ 'the old oracle route',
+ payload({
+ receiverId: 'priceoracle.near',
+ actions: [call('oracle_call', { receiver_id: RHEA_LEND, msg: '{}' })],
+ }),
+ ],
+ [
+ 'a deposit somewhere else',
+ payload({
+ receiverId: USDC_NEAR,
+ actions: [call('ft_transfer_call', { receiver_id: THEM, amount: '5', msg: '' })],
+ }),
+ ],
+ [
+ 'margin through a deposit message',
+ payload({
+ receiverId: USDC_NEAR,
+ actions: [
+ call('ft_transfer_call', {
+ receiver_id: RHEA_LEND,
+ amount: '5',
+ msg: '"DepositToMargin"',
+ }),
+ ],
+ }),
+ ],
+ [
+ 'a repayment for more than was sent',
+ payload({
+ receiverId: USDC_NEAR,
+ actions: [
+ call('ft_transfer_call', {
+ receiver_id: RHEA_LEND,
+ amount: '5',
+ msg: JSON.stringify({
+ Execute: {
+ actions: [{ Repay: { token_id: USDC_NEAR, max_amount: '6000000000000' } }],
+ },
+ }),
+ }),
+ ],
+ }),
+ ],
+ [
+ 'a deposit message that also withdraws',
+ payload({
+ receiverId: USDC_NEAR,
+ actions: [
+ call('ft_transfer_call', {
+ receiver_id: RHEA_LEND,
+ amount: '5',
+ msg: JSON.stringify({ Execute: { actions: [take] } }),
+ }),
+ ],
+ }),
+ ],
+ [
+ 'a plain transfer out',
+ payload({
+ receiverId: USDC_NEAR,
+ actions: [call('ft_transfer', { receiver_id: THEM, amount: '5' })],
+ }),
+ ],
+ [
+ 'an unwrap of NEAR this did not take out',
+ payload({ receiverId: WRAP_NEAR, actions: [call('near_withdraw', { amount: '5' })] }),
+ ],
+ [
+ 'storage paid for someone else',
+ payload({
+ receiverId: RHEA_LEND,
+ actions: [
+ call(
+ 'storage_deposit',
+ { account_id: THEM, registration_only: true },
+ RHEA_LEND_STORAGE_YOCTO
+ ),
+ ],
+ }),
+ ],
+ [
+ 'more storage than the minimum',
+ payload({
+ receiverId: RHEA_LEND,
+ actions: [call('storage_deposit', { account_id: ME, registration_only: true }, yocto(5))],
+ }),
+ ],
+ [
+ 'moving money twice',
+ payload(
+ exec([{ Withdraw: { token_id: USDC_NEAR } }], 'execute'),
+ exec([{ Withdraw: { token_id: USDT_NEAR } }], 'execute')
+ ),
+ ],
+ ['nothing at all', { family: 'near', transactions: [] }],
+ ['another chain', { family: 'sui', transaction: 'x' }],
+ ])('refuses %s', (_name, signing) => {
+ expect(() => assertRheaLendSafe(signing, ME)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses an account that is not one', () => {
+ expect(() => assertRheaLendSafe(payload(exec([take])), 'NOT AN ACCOUNT')).toThrow(/not a NEAR/)
+ })
+})
+
+describe('confirming on Rhea Lend', () => {
+ const borrowCall: FakeTx = { method: 'execute_with_pyth', args: { actions: [] } }
+
+ it('reports what ran', async () => {
+ rpc.tx = () => fakeTx(borrowCall)
+ await expect(confirmRheaLend(signedNear())).resolves.toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('catches a loan refused after the prices came back, though the transaction succeeded', async () => {
+ rpc.tx = () => fakeTx({ ...borrowCall, receipts: [{ executor: RHEA_LEND, failed: true }] })
+ const out = await confirmRheaLend(signedNear())
+ expect(out.ok).toBe(false)
+ expect(out).not.toHaveProperty('pending')
+ expect((out as { error: string }).error).toMatch(/nothing moved/)
+ })
+
+ it('says a loan stands if its money could not reach the wallet', async () => {
+ rpc.tx = () =>
+ fakeTx({
+ ...borrowCall,
+ receipts: [
+ {
+ executor: RHEA_LEND,
+ logs: ['EVENT_JSON:{"data":[],"event":"withdraw_failed","standard":"burrow"}'],
+ },
+ ],
+ })
+ const out = await confirmRheaLend(signedNear())
+ expect(out).toMatchObject({ ok: false, pending: true })
+ expect((out as { error: string }).error).toMatch(/you still owe it/)
+ })
+
+ it('reads a deposit the contract handed back', async () => {
+ rpc.tx = () =>
+ fakeTx({ receiver: USDC_NEAR, method: 'ft_transfer_call', args: {}, returned: '"0"' })
+ const out = await confirmRheaLend(signedNear())
+ expect(out.ok).toBe(false)
+ expect((out as { error: string }).error).toMatch(/came back to your wallet/)
+ })
+
+ it('accepts a deposit the contract kept', async () => {
+ rpc.tx = () =>
+ fakeTx({ receiver: USDC_NEAR, method: 'ft_transfer_call', args: {}, returned: '"5"' })
+ await expect(confirmRheaLend(signedNear())).resolves.toMatchObject({ ok: true })
+ })
+
+ it('says NEAR is there as wNEAR if only the last step failed', async () => {
+ rpc.tx = (hash) =>
+ fakeTx(
+ hash === HASH
+ ? borrowCall
+ : { receiver: WRAP_NEAR, method: 'near_withdraw', args: {}, failed: true }
+ )
+ const out = await confirmRheaLend(signedNear([HASH, HASH2]))
+ expect(out).toMatchObject({ ok: false, pending: true })
+ expect((out as { error: string }).error).toMatch(/as wNEAR/)
+ })
+
+ it.each<[string, FakeTx]>([
+ ['someone else signed', { ...borrowCall, signer: THEM }],
+ ['it ran elsewhere', { ...borrowCall, receiver: 'evil.near' }],
+ ['it is another call', { method: 'storage_withdraw', args: {} }],
+ ['it only registered', { method: 'storage_deposit', args: {} }],
+ ])('will not report a hash where %s', async (_name, tx) => {
+ rpc.tx = () => fakeTx(tx)
+ await expect(confirmRheaLend(signedNear())).resolves.toMatchObject({ ok: false, pending: true })
+ })
+})
+
+describe('the listings', () => {
+ it.each([rheaDexListing, rheaLendListing])('$id ships its logo and says what it does', (v) => {
+ expect(existsSync(join(process.cwd(), `public/images/venues/${v.id}.webp`))).toBe(true)
+ expect(v.integration).toBe('executes')
+ expect(v.capability).toMatch(/Mainnet only/)
+ })
+
+ it('use the ids their venues run under', () => {
+ expect(rheaDexVenue.id).toBe(rheaDexListing.id)
+ expect(rheaLendVenue.id).toBe(rheaLendListing.id)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/route-cap.test.ts b/apps/dapp/lib/__tests__/route-cap.test.ts
deleted file mode 100644
index dfbb046c..00000000
--- a/apps/dapp/lib/__tests__/route-cap.test.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { Keypair } from '@stellar/stellar-sdk'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-import type { ResolvedRecipient } from '../names/resolve'
-import type { MarketPrice } from '../swap/price-types'
-
-/**
- * The mainnet trade cap at the routes, where a client's figures arrive.
- *
- * The cap is checked against what the deployment will actually spend — the
- * re-quoted amount, or the ceiling a fixed-output swap is widened to — and
- * never against the number the browser sent. A quote that claims to spend
- * "1" while asking for a large delivery is the case that matters: the
- * client's send figure is a statement of intent, and the cap must read the
- * fresh quote instead.
- */
-
-const asOf = '2026-09-24T00:00:00.000Z'
-const table: Record = {
- XLM: { symbol: 'XLM', usd: 0.2, source: 'reflector', asOf },
- USDC: { symbol: 'USDC', usd: 1, source: 'stellar-mainnet', asOf },
-}
-
-vi.mock('../swap/prices', async (importOriginal) => ({
- ...(await importOriginal()),
- fetchMarketPrices: () => Promise.resolve(table),
-}))
-
-const resolve = vi.fn<(input: string) => Promise>()
-vi.mock('../names/resolve', () => ({
- resolveRecipient: (input: string) => resolve(input),
-}))
-
-const ME = Keypair.random().publicKey()
-const THEM = Keypair.random().publicKey()
-const USDC = { kind: 'classic' as const, code: 'USDC', issuer: Keypair.random().publicKey() }
-const XLM = { kind: 'classic' as const, code: 'XLM' }
-
-/** Horizon that prices every path at 5 XLM per USDC and knows every account. */
-function horizon(calls: string[]): typeof fetch {
- return ((url: string) => {
- calls.push(url)
- const u = new URL(url)
- if (u.pathname.endsWith('/paths/strict-send')) {
- const usdc = u.searchParams.get('source_amount') ?? '0'
- const xlm = (Number(usdc) * 5).toFixed(7)
- return json({
- _embedded: { records: [{ source_amount: usdc, destination_amount: xlm, path: [] }] },
- })
- }
- if (u.pathname.endsWith('/paths/strict-receive')) {
- const xlm = u.searchParams.get('destination_amount') ?? '0'
- const usdc = (Number(xlm) / 5).toFixed(7)
- return json({
- _embedded: { records: [{ source_amount: usdc, destination_amount: xlm, path: [] }] },
- })
- }
- if (u.pathname.includes('/accounts/')) {
- return json({ id: ME, sequence: '4578890000000001', balances: [] })
- }
- return Promise.resolve(new Response('{}', { status: 500 }))
- }) as unknown as typeof fetch
-}
-
-function json(body: unknown): Promise {
- return Promise.resolve(
- new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
- )
-}
-
-function post(path: string, body: unknown): Request {
- return new Request(`http://localhost${path}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
-}
-
-let calls: string[]
-
-beforeEach(() => {
- calls = []
- vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'mainnet')
- vi.stubEnv('MAINNET_MAX_TRADE_USD', '50')
- vi.stubGlobal('fetch', horizon(calls))
- resolve.mockReset()
-})
-
-afterEach(() => {
- vi.unstubAllEnvs()
- vi.unstubAllGlobals()
-})
-
-describe('POST /api/swap/build on mainnet', () => {
- it('refuses a fixed-input swap over the cap, on the re-quoted amount', async () => {
- const { POST } = await import('../../app/api/swap/build/route')
- const res = await POST(
- post('/api/swap/build', {
- account: ME,
- quote: {
- source: 'horizon',
- kind: 'strict_send',
- from: USDC,
- to: XLM,
- sendAmount: '600000000',
- destAmount: '3000000000',
- path: [],
- quotedAt: new Date().toISOString(),
- },
- })
- )
- expect(res.status).toBe(400)
- const body = (await res.json()) as { error: string }
- expect(body.error).toMatch(/caps each trade at \$50/)
- expect(body.error).toMatch(/\$60\.00/)
- })
-
- it('refuses a fixed-output swap by its ceiling, not by the send figure the client claims', async () => {
- const { POST } = await import('../../app/api/swap/build/route')
- const res = await POST(
- post('/api/swap/build', {
- account: ME,
- quote: {
- source: 'horizon',
- kind: 'strict_receive',
- from: USDC,
- to: XLM,
- // "I will spend 1 stroop" — for 300 XLM, which costs 60 USDC.
- sendAmount: '1',
- destAmount: '3000000000',
- path: [],
- quotedAt: new Date().toISOString(),
- },
- })
- )
- expect(res.status).toBe(400)
- const body = (await res.json()) as { error: string }
- expect(body.error).toMatch(/caps each trade at \$50/)
- // The ceiling: 60 USDC widened by the default tolerance.
- expect(body.error).toMatch(/\$60\.3/)
- // Refused before any account was loaded to build against.
- expect(calls.some((u) => u.includes('/accounts/'))).toBe(false)
- })
-})
-
-describe('POST /api/plan/build on mainnet', () => {
- it('refuses a plan with a step over the cap', async () => {
- const { POST } = await import('../../app/api/plan/build/route')
- const res = await POST(
- post('/api/plan/build', {
- account: ME,
- actions: [{ kind: 'swap', from: USDC, to: XLM, sendAmount: '600000000', minReceive: '1' }],
- })
- )
- expect(res.status).toBe(400)
- expect(((await res.json()) as { error: string }).error).toMatch(/caps each trade at \$50/)
- })
-
- it('builds a plan whose steps are under the cap', async () => {
- const { POST } = await import('../../app/api/plan/build/route')
- const res = await POST(
- post('/api/plan/build', {
- account: ME,
- actions: [{ kind: 'swap', from: USDC, to: XLM, sendAmount: '100000000', minReceive: '1' }],
- })
- )
- expect(res.status).toBe(200)
- expect(((await res.json()) as { xdr?: string }).xdr).toBeTypeOf('string')
- })
-})
-
-describe('POST /api/send/build on mainnet', () => {
- it('values the asset by its canonical code, however the client spelled it', async () => {
- resolve.mockResolvedValue({
- input: 'deon.xlm',
- kind: 'soroban-domain',
- address: THEM,
- resolvedOn: 'stellar-mainnet',
- })
- const { POST } = await import('../../app/api/send/build/route')
- const res = await POST(
- post('/api/send/build', { account: ME, asset: 'xlm', amount: '1000', recipient: 'deon.xlm' })
- )
- expect(res.status).toBe(400)
- const body = (await res.json()) as { error: string }
- expect(body.error).toMatch(/caps each trade at \$50/)
- expect(body.error).not.toMatch(/could not be valued/)
- })
-})
diff --git a/apps/dapp/lib/__tests__/route-rate-limit.test.ts b/apps/dapp/lib/__tests__/route-rate-limit.test.ts
index ba25a0e2..8456eaab 100644
--- a/apps/dapp/lib/__tests__/route-rate-limit.test.ts
+++ b/apps/dapp/lib/__tests__/route-rate-limit.test.ts
@@ -41,6 +41,8 @@ const ROUTES: Record Promise> = {
'perps/session': () => import('../../app/api/perps/session/route'),
'perps/submit': () => import('../../app/api/perps/submit/route'),
'plan/build': () => import('../../app/api/plan/build/route'),
+ 'pools/submit': () => import('../../app/api/pools/submit/route'),
+ 'pools/withdraw': () => import('../../app/api/pools/withdraw/route'),
'plan/submit': () => import('../../app/api/plan/submit/route'),
'send/build': () => import('../../app/api/send/build/route'),
'send/resolve': () => import('../../app/api/send/resolve/route'),
diff --git a/apps/dapp/lib/__tests__/sablier.live.test.ts b/apps/dapp/lib/__tests__/sablier.live.test.ts
new file mode 100644
index 00000000..da0956d6
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sablier.live.test.ts
@@ -0,0 +1,248 @@
+import {
+ decodeFunctionResult,
+ encodeAbiParameters,
+ erc20Abi,
+ getAddress,
+ http,
+ keccak256,
+ parseAbi,
+ toFunctionSelector,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import { evmReader } from '../chains/evm/client'
+import {
+ createSablierVenue,
+ SABLIER_COMPTROLLER,
+ SABLIER_LOCKUP,
+ SABLIER_LOCKUP_ABI,
+ SABLIER_STREAMABLE,
+} from '../chains/evm/sablier'
+import { EVM_CHAINS, type EvmChain } from '../chains/evm/tokens'
+import type { SigningPayload } from '../chains/types'
+
+/**
+ * The real contracts, read-only: nothing signs or sends. The stream is tried with `eth_call`, with
+ * the sender's balance and allowance overridden for that one call, so the real contract runs it.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SENDER = getAddress('0x1111111111111111111111111111111111111111')
+const RECIPIENT = getAddress('0x2222222222222222222222222222222222222222')
+const CHAINS: EvmChain[] = ['avalanche', 'monad']
+
+// Slots in Circle's FiatToken: balanceAndBlacklistStates is 9, allowed is 10.
+const USDC_BALANCE_SLOT = BigInt(9)
+const USDC_ALLOWANCE_SLOT = BigInt(10)
+
+const VIEWS = parseAbi([
+ 'function name() view returns (string)',
+ 'function nextStreamId() view returns (uint256)',
+ 'function comptroller() view returns (address)',
+ 'function getSender(uint256 streamId) view returns (address)',
+ 'function calculateMinFeeWei(uint256 streamId) view returns (uint256)',
+])
+
+// The public RPCs rate-limit a burst, so these reads go one at a time with room to retry.
+function patientReader(chain: EvmChain) {
+ const url = process.env[EVM_CHAINS[chain].rpcEnv]?.trim() || undefined
+ return evmReader(chain, http(url, { timeout: 15_000, retryCount: 6, retryDelay: 2_000 }))
+}
+
+const FEES = parseAbi([
+ 'function VERSION() view returns (string)',
+ 'function MAX_FEE_USD() view returns (uint256)',
+ 'function admin() view returns (address)',
+ 'function oracle() view returns (address)',
+ 'function getMinFeeUSD(uint8 protocol) view returns (uint256)',
+ 'function getMinFeeUSDFor(uint8 protocol, address user) view returns (uint256)',
+ 'function calculateMinFeeWeiFor(uint8 protocol, address user) view returns (uint256)',
+])
+
+const EIP1967_IMPLEMENTATION = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
+
+function mappingSlot(key: Address | Hex, slot: bigint): Hex {
+ const type = key.length === 42 ? 'address' : 'bytes32'
+ return keccak256(encodeAbiParameters([{ type }, { type: 'uint256' }], [key, slot] as never))
+}
+
+function usdcOf(chain: EvmChain): Address {
+ const token = EVM_CHAINS[chain].tokens.find((t) => t.symbol === 'USDC')
+ return token?.id as Address
+}
+
+describe.skipIf(SKIP)('Sablier Lockup against mainnet', () => {
+ describe.each(CHAINS)('%s', (chain) => {
+ const reader = patientReader(chain)
+ const lockup = SABLIER_LOCKUP[chain] as Address
+
+ it('has the v4.0 contract at the pinned address', async () => {
+ const code = await reader.getCode({ address: lockup })
+ expect((code ?? '0x').length).toBeGreaterThan(2)
+ expect(code).toContain(toFunctionSelector(SABLIER_LOCKUP_ABI[0]).slice(2))
+ const name = await reader.readContract({ address: lockup, abi: VIEWS, functionName: 'name' })
+ expect(name).toBe('Sablier Lockup NFT')
+ }, 60_000)
+
+ it('uses the pinned comptroller, which reads a capped fee', async () => {
+ const comptroller = await reader.readContract({
+ address: lockup,
+ abi: VIEWS,
+ functionName: 'comptroller',
+ })
+ expect(getAddress(comptroller)).toBe(SABLIER_COMPTROLLER)
+ const address = SABLIER_COMPTROLLER as Address
+ const version = await reader.readContract({ address, abi: FEES, functionName: 'VERSION' })
+ const max = await reader.readContract({ address, abi: FEES, functionName: 'MAX_FEE_USD' })
+ const admin = await reader.readContract({ address, abi: FEES, functionName: 'admin' })
+ const oracle = await reader.readContract({ address, abi: FEES, functionName: 'oracle' })
+ const usd = await reader.readContract({
+ address,
+ abi: FEES,
+ functionName: 'getMinFeeUSDFor',
+ args: [2, SENDER],
+ })
+ const wei = await reader.readContract({
+ address,
+ abi: FEES,
+ functionName: 'calculateMinFeeWeiFor',
+ args: [2, SENDER],
+ })
+ const implementation = await reader.getStorageAt({ address, slot: EIP1967_IMPLEMENTATION })
+ const adminCode = await reader.getCode({ address: admin })
+ console.info(chain, {
+ version,
+ max,
+ admin,
+ adminCodeBytes: ((adminCode ?? '0x').length - 2) / 2,
+ oracle,
+ usd,
+ wei,
+ implementation,
+ })
+ expect(max).toBe(BigInt(10_000_000_000))
+ expect(usd).toBeLessThanOrEqual(max)
+ // The card says a single key holds this. If that changes, the card should too.
+ expect((adminCode ?? '0x').length).toBe(2)
+ const defaults = []
+ for (const protocol of [0, 1, 2, 3, 4]) {
+ defaults.push(
+ await reader.readContract({
+ address,
+ abi: FEES,
+ functionName: 'getMinFeeUSD',
+ args: [protocol],
+ })
+ )
+ }
+ console.info(chain, { defaultFeesUsdByProtocol: defaults })
+ }, 120_000)
+
+ it('reads the fee under the same protocol number Lockup itself asks with', async () => {
+ const next = await reader.readContract({
+ address: lockup,
+ abi: VIEWS,
+ functionName: 'nextStreamId',
+ })
+ console.info(chain, { nextStreamId: next })
+ if (next <= BigInt(1)) return
+ const streamId = next - BigInt(1)
+ const sender = await reader.readContract({
+ address: lockup,
+ abi: VIEWS,
+ functionName: 'getSender',
+ args: [streamId],
+ })
+ const fromLockup = await reader.readContract({
+ address: lockup,
+ abi: VIEWS,
+ functionName: 'calculateMinFeeWei',
+ args: [streamId],
+ })
+ const fromComptroller = await reader.readContract({
+ address: SABLIER_COMPTROLLER as Address,
+ abi: FEES,
+ functionName: 'calculateMinFeeWeiFor',
+ args: [2, sender],
+ })
+ console.info(chain, { streamId, sender, fromLockup, fromComptroller })
+ expect(fromComptroller).toBe(fromLockup)
+ }, 120_000)
+
+ it.each(SABLIER_STREAMABLE[chain])(
+ '%s is a real token with six decimals',
+ async (symbol) => {
+ const token = EVM_CHAINS[chain].tokens.find((t) => t.symbol === symbol)
+ const address = token?.id as Address
+ const decimals = await reader.readContract({
+ address,
+ abi: erc20Abi,
+ functionName: 'decimals',
+ })
+ expect(decimals).toBe(token?.decimals)
+ expect(decimals).toBe(6)
+ },
+ 60_000
+ )
+
+ it('runs the built stream on the real contract', async () => {
+ const venue = createSablierVenue(chain, reader)
+ const prepared = await venue.prepare(
+ {
+ kind: 'stream',
+ amount: '5',
+ tokenIn: 'USDC',
+ recipient: RECIPIENT,
+ durationDays: '30',
+ },
+ SENDER
+ )
+ const signing = prepared.signing as Extract
+ expect(signing.approval).toBeDefined()
+ expect(prepared.notes.join('\n')).toContain('bug bounty ended on May 1, 2026')
+ console.info(chain, prepared.notes)
+
+ const usdc = usdcOf(chain)
+ const balanceSlot = mappingSlot(SENDER, USDC_BALANCE_SLOT)
+ const allowanceSlot = mappingSlot(
+ lockup,
+ keccak256(
+ encodeAbiParameters(
+ [{ type: 'address' }, { type: 'uint256' }],
+ [SENDER, USDC_ALLOWANCE_SLOT]
+ )
+ ) as never
+ )
+ const lots = `0x${BigInt(1_000_000_000).toString(16).padStart(64, '0')}` as Hex
+ const stateOverride = [
+ {
+ address: usdc,
+ stateDiff: [
+ { slot: balanceSlot, value: lots },
+ { slot: allowanceSlot, value: lots },
+ ],
+ },
+ ]
+
+ const result = await reader.call({
+ account: SENDER,
+ to: signing.to as Address,
+ data: signing.data as Hex,
+ stateOverride,
+ })
+ const streamId = decodeFunctionResult({
+ abi: SABLIER_LOCKUP_ABI,
+ functionName: 'createWithDurationsLL',
+ data: result.data as Hex,
+ })
+ const next = await reader.readContract({
+ address: lockup,
+ abi: VIEWS,
+ functionName: 'nextStreamId',
+ })
+ expect(streamId).toBe(next)
+ }, 180_000)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/sablier.test.ts b/apps/dapp/lib/__tests__/sablier.test.ts
new file mode 100644
index 00000000..4a079be6
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sablier.test.ts
@@ -0,0 +1,814 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+
+import {
+ encodeFunctionData,
+ erc20Abi,
+ getAddress,
+ maxUint256,
+ parseAbi,
+ toFunctionSelector,
+ zeroAddress,
+ type Address,
+ type Hex,
+} from 'viem'
+import { describe, expect, it } from 'vitest'
+
+import type { EvmReaderClient } from '../chains/evm/call'
+import type { EvmClient, EvmReceipt } from '../chains/evm/client'
+import {
+ assertStreamAsAsked,
+ createSablierVenue,
+ SABLIER_COMPTROLLER,
+ SABLIER_LOCKUP,
+ SABLIER_LOCKUP_ABI,
+ sablierAvalancheVenue,
+ sablierMonadVenue,
+} from '../chains/evm/sablier'
+import { sablierVenues } from '../chains/evm/sablier-listing'
+import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types'
+
+const ME = getAddress('0x1111111111111111111111111111111111111111')
+const THEM = getAddress('0x2222222222222222222222222222222222222222')
+const STRANGER = getAddress('0x3333333333333333333333333333333333333333')
+const LOCKUP = SABLIER_LOCKUP.avalanche as Address
+const USDC = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E' as Address
+const USDT = '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7' as Address
+const MONAD_USDC = '0x754704Bc059F8C67012fEd69BC8A327a5aafb603' as Address
+const AVALANCHE_ID = 43114
+const MONAD_ID = 143
+const DAY = 86_400
+const FIVE_HUNDRED = BigInt(500_000_000)
+
+const REQUEST: ChainActionRequest = {
+ kind: 'stream',
+ amount: '500',
+ tokenIn: 'USDC',
+ recipient: THEM,
+ durationDays: '30',
+}
+
+interface StreamArgs {
+ sender: Address
+ recipient: Address
+ depositAmount: bigint
+ token: Address
+ cancelable: boolean
+ transferable: boolean
+ shape: string
+ start: bigint
+ cliffUnlock: bigint
+ granularity: number
+ cliff: number
+ total: number
+}
+
+const GOOD: StreamArgs = {
+ sender: ME,
+ recipient: THEM,
+ depositAmount: FIVE_HUNDRED,
+ token: USDC,
+ cancelable: true,
+ transferable: false,
+ shape: 'linear',
+ start: BigInt(0),
+ cliffUnlock: BigInt(0),
+ granularity: 1,
+ cliff: 0,
+ total: 30 * DAY,
+}
+
+function create(overrides: Partial = {}): Hex {
+ const a = { ...GOOD, ...overrides }
+ return encodeFunctionData({
+ abi: SABLIER_LOCKUP_ABI,
+ functionName: 'createWithDurationsLL',
+ args: [
+ {
+ sender: a.sender,
+ recipient: a.recipient,
+ depositAmount: a.depositAmount,
+ token: a.token,
+ cancelable: a.cancelable,
+ transferable: a.transferable,
+ shape: a.shape,
+ },
+ { start: a.start, cliff: a.cliffUnlock },
+ a.granularity,
+ { cliff: a.cliff, total: a.total },
+ ],
+ })
+}
+
+function approve(spender: Address = LOCKUP, units: bigint = FIVE_HUNDRED): Hex {
+ return encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender, units] })
+}
+
+function payload(
+ overrides: Partial> = {}
+): Extract {
+ return {
+ family: 'evm',
+ chainId: AVALANCHE_ID,
+ to: LOCKUP,
+ data: create(),
+ value: '0',
+ ...overrides,
+ }
+}
+
+interface FakeOptions {
+ allowance?: bigint
+ callFails?: boolean
+ feeFails?: boolean
+ feeWei?: bigint
+ comptroller?: string
+}
+
+function fakeReader(options: FakeOptions = {}) {
+ const calls: { to: string; data: string }[] = []
+ const reader = {
+ readContract: async ({ functionName }: { functionName: string }) => {
+ switch (functionName) {
+ case 'allowance':
+ return options.allowance ?? BigInt(0)
+ case 'comptroller':
+ if (options.feeFails === true) throw new Error('rpc down')
+ return options.comptroller ?? SABLIER_COMPTROLLER
+ case 'calculateMinFeeWeiFor':
+ if (options.feeFails === true) throw new Error('rpc down')
+ return options.feeWei ?? BigInt('98770000000000000')
+ case 'getMinFeeUSDFor':
+ return BigInt(99_000_000)
+ case 'MAX_FEE_USD':
+ return BigInt(10_000_000_000)
+ default:
+ throw new Error(`unexpected read ${functionName}`)
+ }
+ },
+ call: async (args: { to: string; data: string }) => {
+ calls.push(args)
+ if (options.callFails === true) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ } as unknown as EvmReaderClient
+ return { reader, calls }
+}
+
+function venueWith(options: FakeOptions = {}) {
+ const fake = fakeReader(options)
+ return { venue: createSablierVenue('avalanche', fake.reader), calls: fake.calls }
+}
+
+interface FakeStream {
+ sender?: Address
+ status?: number
+ cancelable?: boolean
+ refundable?: bigint
+ withdrawable?: bigint
+ token?: Address
+ recipient?: Address
+}
+
+const LIVE: Required = {
+ sender: ME,
+ status: 1,
+ cancelable: true,
+ refundable: BigInt(300_000_000),
+ withdrawable: BigInt(200_000_000),
+ token: USDC,
+ recipient: THEM,
+}
+
+/** Stream ids run from 1, as they do on the chain. */
+function streamReader(
+ streams: FakeStream[],
+ options: { callFails?: boolean; readFails?: string; nextOverride?: bigint } = {}
+) {
+ const calls: { to: string; data: string; value?: bigint }[] = []
+ const readContract = async ({
+ functionName,
+ args,
+ }: {
+ functionName: string
+ args?: readonly unknown[]
+ }): Promise => {
+ if (options.readFails === functionName) throw new Error('rpc down')
+ if (functionName === 'nextStreamId') {
+ return options.nextOverride ?? BigInt(streams.length + 1)
+ }
+ const stream = { ...LIVE, ...streams[Number(args?.[0]) - 1] }
+ switch (functionName) {
+ case 'getSender':
+ return stream.sender
+ case 'statusOf':
+ return stream.status
+ case 'isCancelable':
+ return stream.cancelable
+ case 'refundableAmountOf':
+ return stream.refundable
+ case 'withdrawableAmountOf':
+ return stream.withdrawable
+ case 'getUnderlyingToken':
+ return stream.token
+ case 'getRecipient':
+ return stream.recipient
+ default:
+ throw new Error(`unexpected read ${functionName}`)
+ }
+ }
+ const reader = {
+ readContract,
+ multicall: async ({ contracts }: { contracts: { functionName: string }[] }) =>
+ Promise.all(contracts.map(readContract)),
+ call: async (args: { to: string; data: string }) => {
+ calls.push(args)
+ if (options.callFails === true) throw new Error('reverted')
+ return { data: '0x' }
+ },
+ } as unknown as EvmReaderClient
+ return { venue: createSablierVenue('avalanche', reader), calls }
+}
+
+function cancelData(streamId: bigint = BigInt(1)): Hex {
+ return encodeFunctionData({ abi: SABLIER_LOCKUP_ABI, functionName: 'cancel', args: [streamId] })
+}
+
+const CANCEL: ChainActionRequest = {
+ kind: 'cancel',
+ amount: 'all',
+ tokenIn: 'STREAM',
+ streamId: '1',
+}
+
+function refusal(run: () => unknown): string {
+ try {
+ run()
+ } catch (e) {
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ return (e as Error).message
+ }
+ throw new Error('expected a refusal')
+}
+
+async function refusalOf(run: () => Promise): Promise {
+ try {
+ await run()
+ } catch (e) {
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ return (e as Error).message
+ }
+ throw new Error('expected a refusal')
+}
+
+describe('the pinned call', () => {
+ it('encodes the selector of Lockup v4.0 createWithDurationsLL', () => {
+ expect(create().slice(0, 10)).toBe('0x7b4dae9e')
+ })
+
+ it('has no broker argument anywhere, since v3.0 removed it', () => {
+ expect(JSON.stringify(SABLIER_LOCKUP_ABI)).not.toMatch(/broker/i)
+ })
+
+ it('funds the stream from the two chains’ own Lockup contracts', () => {
+ expect(SABLIER_LOCKUP.avalanche).toBe('0xB891b41533776Ec20f7738c647a11506AA44b8A8')
+ expect(SABLIER_LOCKUP.monad).toBe('0x82723C1ffEc9D43dE5FA80b25Da8df99AfD470ba')
+ })
+})
+
+describe('assertSafe', () => {
+ const venue = sablierAvalancheVenue
+
+ it('passes the stream as this venue builds it', () => {
+ expect(() => venue.assertSafe(payload(), ME)).not.toThrow()
+ })
+
+ it('passes it with an exact approval to the Lockup contract', () => {
+ const signing = payload({ approval: { to: USDC, data: approve() } })
+ expect(() => venue.assertSafe(signing, ME)).not.toThrow()
+ })
+
+ it('passes a USDT stream with its own approval', () => {
+ const signing = payload({
+ data: create({ token: USDT }),
+ approval: { to: USDT, data: approve() },
+ })
+ expect(() => venue.assertSafe(signing, ME)).not.toThrow()
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'sui', transaction: 'AA==' }],
+ ['another chain', payload({ chainId: MONAD_ID })],
+ ['an unknown contract', payload({ to: STRANGER })],
+ ['the other chain’s Lockup', payload({ to: SABLIER_LOCKUP.monad as string })],
+ ['native coin as value', payload({ value: '1' })],
+ ['a value that is not a number', payload({ value: 'abc' })],
+ ['extra calldata', payload({ data: `${create()}00` })],
+ ['a stream from someone else as sender', payload({ data: create({ sender: STRANGER }) })],
+ ['the person streaming to themselves', payload({ data: create({ recipient: ME }) })],
+ ['the zero address as recipient', payload({ data: create({ recipient: zeroAddress }) })],
+ ['the Lockup contract as recipient', payload({ data: create({ recipient: LOCKUP }) })],
+ ['a token contract as recipient', payload({ data: create({ recipient: USDC }) })],
+ ['a token this venue does not stream', payload({ data: create({ token: STRANGER }) })],
+ ['a Monad token on Avalanche', payload({ data: create({ token: MONAD_USDC }) })],
+ ['a stream that cannot be canceled', payload({ data: create({ cancelable: false }) })],
+ ['a stream that can be moved', payload({ data: create({ transferable: true }) })],
+ ['a stream paying out at its start', payload({ data: create({ start: BigInt(1) }) })],
+ ['a stream paying out at a cliff', payload({ data: create({ cliffUnlock: BigInt(1) }) })],
+ ['a cliff', payload({ data: create({ cliff: DAY }) })],
+ ['a coarser unlock step', payload({ data: create({ granularity: DAY }) })],
+ ['another shape', payload({ data: create({ shape: 'x'.repeat(8) }) })],
+ ['a length of zero', payload({ data: create({ total: 0 }) })],
+ ['a length that is not whole days', payload({ data: create({ total: DAY + 1 }) })],
+ ['a length past what can be typed', payload({ data: create({ total: 100_000 * DAY }) })],
+ ])('refuses %s', (_name, signing) => {
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses the wrong signer', () => {
+ expect(refusal(() => venue.assertSafe(payload(), STRANGER))).toMatch(/your wallet/)
+ })
+
+ it.each([
+ [
+ 'withdraw',
+ encodeFunctionData({
+ abi: parseAbi(['function withdraw(uint256 streamId, address to, uint128 amount)']),
+ functionName: 'withdraw',
+ args: [BigInt(1), STRANGER, BigInt(1)],
+ }),
+ ],
+ [
+ 'createWithTimestampsLL',
+ encodeFunctionData({
+ abi: parseAbi([
+ 'struct T { uint40 start; uint40 end; }',
+ 'struct P { address a; address b; uint128 c; address d; bool e; bool f; T g; string h; }',
+ 'function createWithTimestampsLL(P p, (uint128,uint128) u, uint40 g, uint40 c)',
+ ]),
+ functionName: 'createWithTimestampsLL',
+ args: [
+ {
+ a: ME,
+ b: THEM,
+ c: FIVE_HUNDRED,
+ d: USDC,
+ e: true,
+ f: false,
+ g: { start: 1, end: 2 },
+ h: 'linear',
+ },
+ [BigInt(0), BigInt(0)],
+ 1,
+ 0,
+ ],
+ }),
+ ],
+ [
+ 'the v2 form that paid a broker',
+ encodeFunctionData({
+ abi: parseAbi([
+ 'struct B { address account; uint256 fee; }',
+ 'struct P { address a; address b; uint128 c; address d; bool e; bool f; B broker; }',
+ 'function createWithDurationsLL(P p, (uint128,uint128) u, (uint40,uint40) d)',
+ ]),
+ functionName: 'createWithDurationsLL',
+ args: [
+ {
+ a: ME,
+ b: THEM,
+ c: FIVE_HUNDRED,
+ d: USDC,
+ e: true,
+ f: false,
+ broker: { account: STRANGER, fee: BigInt(1) },
+ },
+ [BigInt(0), BigInt(0)],
+ [0, DAY],
+ ],
+ }),
+ ],
+ ])('refuses the function %s', (_name, data) => {
+ expect(toFunctionSelector(SABLIER_LOCKUP_ABI[0])).not.toBe(data.slice(0, 10))
+ expect(refusal(() => venue.assertSafe(payload({ data }), ME))).toMatch(/haven't confirmed/)
+ })
+
+ it('refuses an approval to someone else', () => {
+ const signing = payload({ approval: { to: USDC, data: approve(STRANGER) } })
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/isn't confirmed/)
+ })
+
+ it('refuses an unlimited approval', () => {
+ const signing = payload({ approval: { to: USDC, data: approve(LOCKUP, maxUint256) } })
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/unlimited/)
+ })
+
+ it('refuses an approval larger than the amount', () => {
+ const signing = payload({
+ approval: { to: USDC, data: approve(LOCKUP, FIVE_HUNDRED + BigInt(1)) },
+ })
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/exactly/)
+ })
+
+ it('refuses an approval smaller than the amount', () => {
+ const signing = payload({
+ approval: { to: USDC, data: approve(LOCKUP, FIVE_HUNDRED - BigInt(1)) },
+ })
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/exactly/)
+ })
+
+ it('refuses an approval of a different token than the stream’s', () => {
+ const signing = payload({ approval: { to: USDT, data: approve() } })
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/different token/)
+ })
+
+ it('refuses an approval of a token it does not know', () => {
+ const signing = payload({ approval: { to: STRANGER, data: approve() } })
+ expect(refusal(() => venue.assertSafe(signing, ME))).toMatch(/haven't confirmed/)
+ })
+})
+
+describe('assertStreamAsAsked', () => {
+ const asked = (signing: SigningPayload, request: ChainActionRequest = REQUEST) =>
+ assertStreamAsAsked(signing, request, ME, 'avalanche')
+
+ it('passes a stream that is what was typed', () => {
+ expect(() => asked(payload())).not.toThrow()
+ })
+
+ it('reads a typed address in any case as the same address', () => {
+ const request = { ...REQUEST, recipient: THEM.toLowerCase() }
+ expect(() => asked(payload(), request)).not.toThrow()
+ })
+
+ it.each<[string, Partial, RegExp]>([
+ ['another recipient', { recipient: STRANGER }, /address you typed/],
+ ['a shorter length', { total: 29 * DAY }, /days/],
+ ['another amount', { depositAmount: FIVE_HUNDRED - BigInt(1) }, /amount/],
+ ['another token', { token: USDT }, /token/],
+ ['another sender', { sender: STRANGER }, /your wallet/],
+ ])('refuses %s', (_name, overrides, reason) => {
+ const message = refusal(() => asked(payload({ data: create(overrides) })))
+ expect(message).toMatch(reason)
+ expect(message).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses something that is not an EVM call', () => {
+ expect(refusal(() => asked({ family: 'sui', transaction: 'AA==' }))).toMatch(/not/)
+ })
+})
+
+describe('prepare', () => {
+ it('builds exactly the stream that was typed, approving first when it must', async () => {
+ const { venue } = venueWith()
+ const prepared = await venue.prepare(REQUEST, ME)
+ expect(prepared.venueId).toBe('sablier-avalanche')
+ expect(prepared.kind).toBe('stream')
+ expect(prepared.pay).toEqual({ amount: '500', symbol: 'USDC' })
+ expect(prepared.summary).toContain(THEM)
+ expect(prepared.summary).toContain('30 days')
+ expect(prepared.signing).toEqual({
+ family: 'evm',
+ chainId: AVALANCHE_ID,
+ to: LOCKUP,
+ data: create(),
+ value: '0',
+ approval: { to: USDC, data: approve() },
+ })
+ expect(prepared.notes.some((note) => note.includes('asks twice'))).toBe(true)
+ })
+
+ it('tries the call when no approval is needed, and asks only once', async () => {
+ const { venue, calls } = venueWith({ allowance: FIVE_HUNDRED })
+ const prepared = await venue.prepare(REQUEST, ME)
+ const signing = prepared.signing as Extract
+ expect(signing.approval).toBeUndefined()
+ expect(calls).toHaveLength(1)
+ expect(prepared.notes.some((note) => note.includes('asks twice'))).toBe(false)
+ })
+
+ it('refuses a stream that would fail on the chain', async () => {
+ const { venue } = venueWith({ allowance: FIVE_HUNDRED, callFails: true })
+ expect(await refusalOf(() => venue.prepare(REQUEST, ME))).toMatch(/would fail/)
+ })
+
+ it('passes its own assertSafe', async () => {
+ const { venue } = venueWith()
+ const prepared = await venue.prepare(REQUEST, ME)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('builds for Monad against Monad’s contract and token', async () => {
+ const { reader } = fakeReader()
+ const prepared = await createSablierVenue('monad', reader).prepare(REQUEST, ME)
+ const signing = prepared.signing as Extract
+ expect(prepared.venueId).toBe('sablier-monad')
+ expect(signing.chainId).toBe(MONAD_ID)
+ expect(signing.to).toBe(SABLIER_LOCKUP.monad)
+ expect(signing.approval?.to).toBe(MONAD_USDC)
+ expect(prepared.notes.join(' ')).toContain('MON')
+ })
+
+ it.each<[string, Partial, RegExp]>([
+ ['another kind', { kind: 'send' }, /only streams/],
+ ['the native coin', { tokenIn: 'AVAX' }, /USDC or USDT/],
+ ['a token it does not stream', { tokenIn: 'DOGE' }, /USDC or USDT/],
+ ['a token named with another chain’s symbol', { tokenIn: 'USDT0' }, /USDC or USDT/],
+ ['no recipient', { recipient: undefined }, /address/],
+ ['a bad checksum', { recipient: '0xb891b41533776Ec20f7738c647a11506AA44b8A8' }, /address/],
+ ['the zero address', { recipient: zeroAddress }, /address/],
+ ['a token as the recipient', { recipient: USDC }, /address/],
+ ['the Lockup as the recipient', { recipient: LOCKUP }, /Sablier/],
+ ['the fee contract as the recipient', { recipient: SABLIER_COMPTROLLER }, /Sablier/],
+ ['the person themselves', { recipient: ME }, /your own/],
+ ['no length', { durationDays: undefined }, /days/],
+ ['a length of zero', { durationDays: '0' }, /days/],
+ ['a part of a day', { durationDays: '1.5' }, /days/],
+ ['too long a length', { durationDays: '100000' }, /days/],
+ ['an amount of zero', { amount: '0' }, /more than zero/],
+ ['too many decimals', { amount: '1.0000001' }, /decimal/],
+ ['an amount past what a stream holds', { amount: '9'.repeat(39) }, /too large/],
+ ])('refuses %s', async (_name, overrides, reason) => {
+ const { venue, calls } = venueWith()
+ const message = await refusalOf(() => venue.prepare({ ...REQUEST, ...overrides }, ME))
+ expect(message).toMatch(reason)
+ expect(calls).toHaveLength(0)
+ })
+
+ it('refuses a malformed account', async () => {
+ const { venue } = venueWith()
+ expect(await refusalOf(() => venue.prepare(REQUEST, 'not-an-address'))).toMatch(/wallet/)
+ })
+
+ it('refuses when Lockup points at a fee contract nobody checked', async () => {
+ const { venue } = venueWith({ comptroller: STRANGER })
+ expect(await refusalOf(() => venue.prepare(REQUEST, ME))).toMatch(/fee contract/)
+ })
+})
+
+describe('what the card says', () => {
+ async function notes(options: FakeOptions = {}): Promise {
+ const { venue } = venueWith(options)
+ return (await venue.prepare(REQUEST, ME)).notes.join('\n')
+ }
+
+ it('says the stream is cancelable, what that does, and how', async () => {
+ const text = await notes()
+ expect(text).toMatch(/You can cancel/)
+ expect(text).toContain('not yet streamed comes back')
+ expect(text).toMatch(/asking here to cancel the stream by its number/)
+ expect(text).toContain('app.sablier.com')
+ })
+
+ it('says the recipient must withdraw what has streamed', async () => {
+ expect(await notes()).toMatch(new RegExp(`${THEM} has to withdraw`))
+ })
+
+ it('shows the withdrawal fee read from the chain', async () => {
+ const text = await notes()
+ expect(text).toContain('0.09877 AVAX')
+ expect(text).toContain('0.99 dollars')
+ expect(text).toContain('100 dollars')
+ })
+
+ it('says so plainly when the fee reads as zero', async () => {
+ const text = await notes({ feeWei: BigInt(0) })
+ expect(text).toContain('reads as zero right now')
+ expect(text).toContain('its own site charges 0.99 dollars')
+ })
+
+ it('does not count the site’s charge on top of a fee the contract asks for', async () => {
+ expect(await notes()).not.toContain('its own site charges')
+ })
+
+ it('shows no figure when the fee cannot be read', async () => {
+ const text = await notes({ feeFails: true })
+ expect(text).toContain("couldn't read Sablier's withdrawal fee")
+ expect(text).not.toMatch(/\d AVAX/)
+ })
+
+ it('says creating it pays no one else', async () => {
+ expect(await notes()).toMatch(/no fee to create/)
+ })
+
+ it('names the ended bug bounty', async () => {
+ expect(await notes()).toContain('bug bounty ended on May 1, 2026')
+ })
+
+ it('names who can change what', async () => {
+ const text = await notes()
+ expect(text).toContain("can't be upgraded")
+ expect(text).toContain('no waiting period')
+ expect(text).toContain('A single key')
+ })
+
+ it('has no em dash', async () => {
+ expect(await notes()).not.toContain('—')
+ })
+})
+
+describe('submit', () => {
+ const HASH = `0x${'a'.repeat(64)}`
+
+ function client(receipt: Partial, data: string = create()): EvmClient {
+ return {
+ tokenBalance: async () => BigInt(0),
+ nativeBalance: async () => BigInt(0),
+ waitForReceipt: async () =>
+ ({ status: 'success', from: ME, to: LOCKUP, logs: [], ...receipt }) as EvmReceipt,
+ transaction: async () => ({ value: BigInt(0), input: data }),
+ }
+ }
+
+ function submit(fake: EvmClient) {
+ const { reader } = fakeReader()
+ return createSablierVenue('avalanche', reader, fake).submit?.({
+ account: ME,
+ venueId: 'sablier-avalanche',
+ signed: { family: 'evm', hash: HASH },
+ })
+ }
+
+ it('confirms what the chain says was sent, with no stream id needed', async () => {
+ const result = await submit(client({}))
+ expect(result).toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('reports a revert of the real transaction as nothing moved', async () => {
+ const result = await submit(client({ status: 'reverted' }))
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it('does not vouch for a transaction that streams from someone else', async () => {
+ const result = await submit(client({}, create({ sender: STRANGER })))
+ expect(result).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('does not vouch for a transaction from another wallet', async () => {
+ const result = await submit(client({ from: STRANGER }))
+ expect(result).toMatchObject({ ok: false, pending: true })
+ })
+})
+
+describe('ending a stream', () => {
+ it('builds a cancel from the sender, with nothing else in it', async () => {
+ const { venue, calls } = streamReader([{}])
+ const prepared = await venue.prepare(CANCEL, ME)
+ expect(prepared).toMatchObject({
+ kind: 'cancel',
+ pay: { amount: '300', symbol: 'USDC' },
+ receive: { amount: '300', symbol: 'USDC', approximate: true },
+ signing: { family: 'evm', to: LOCKUP, data: cancelData(), value: '0' },
+ })
+ expect(prepared.signing).not.toHaveProperty('approval')
+ expect(calls).toHaveLength(1)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('says where the money goes, what the recipient keeps, and that there is no fee', async () => {
+ const { venue } = streamReader([{}])
+ const { notes, summary } = await venue.prepare(CANCEL, ME)
+ const text = notes.join(' ')
+ expect(summary).toContain(THEM)
+ expect(text).toMatch(/comes back to your wallet in this same transaction/)
+ expect(text).toMatch(new RegExp(`${THEM} keeps what has already streamed`))
+ expect(text).toContain('About 200 USDC')
+ expect(text).toMatch(/asks no fee to cancel/)
+ })
+
+ it.each<[string, FakeStream[], string, RegExp]>([
+ ['someone else’s stream', [{ sender: STRANGER }], '1', /wasn't started by this wallet/],
+ ['a stream already canceled', [{ status: 3 }], '1', /already canceled/],
+ ['a stream that has run its course', [{ status: 2 }], '1', /finished streaming/],
+ ['a stream fully paid out', [{ status: 4 }], '1', /finished streaming/],
+ ['a stream that can’t be canceled', [{ cancelable: false }], '1', /can't be canceled/],
+ ['a stream with nothing left', [{ refundable: BigInt(0) }], '1', /nothing left to get back/],
+ [
+ 'a token nobody confirmed, which proves nothing about who started it',
+ [{ token: STRANGER }],
+ '1',
+ /a token I haven't confirmed/,
+ ],
+ ['a stream that does not exist', [{}], '2', /There is no stream 2/],
+ ['a stream id of nothing', [{}], '0', /isn't a stream number/],
+ ['a stream id that is not a number', [{}], 'AbC123', /isn't a stream number/],
+ ])('refuses %s', async (_label, streams, streamId, words) => {
+ const { venue } = streamReader(streams)
+ const asked = venue.prepare({ ...CANCEL, streamId }, ME)
+ await expect(asked).rejects.toThrow(ChainActionRefused)
+ await expect(asked).rejects.toThrow(words)
+ await expect(asked).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses with no stream id at all', async () => {
+ const { venue } = streamReader([{}])
+ const { streamId: _none, ...noId } = CANCEL
+ await expect(venue.prepare(noId, ME)).rejects.toThrow(/isn't a stream number/)
+ })
+
+ it('refuses when the stream can not be read, or would fail', async () => {
+ await expect(
+ streamReader([{}], { readFails: 'getSender' }).venue.prepare(CANCEL, ME)
+ ).rejects.toThrow(/couldn't read stream 1/)
+ await expect(streamReader([{}], { callFails: true }).venue.prepare(CANCEL, ME)).rejects.toThrow(
+ /keeps running as it was/
+ )
+ })
+
+ it('still builds when only the recipient can not be read', async () => {
+ const { venue } = streamReader([{}], { readFails: 'getRecipient' })
+ const { summary } = await venue.prepare(CANCEL, ME)
+ expect(summary).toContain('the person being paid')
+ })
+
+ it('assertSafe passes the cancel and nothing wider', () => {
+ const { venue } = streamReader([{}])
+ expect(() => venue.assertSafe(payload({ data: cancelData() }), ME)).not.toThrow()
+ expect(() => venue.assertSafe(payload({ data: cancelData(BigInt(0)) }), ME)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => venue.assertSafe(payload({ data: `${cancelData()}00` as Hex }), ME)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => venue.assertSafe(payload({ data: cancelData(), value: '1' }), ME)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => venue.assertSafe(payload({ data: cancelData(), to: STRANGER }), ME)).toThrow(
+ ChainActionRefused
+ )
+ expect(() =>
+ venue.assertSafe(payload({ data: cancelData(), approval: { to: USDC, data: approve() } }), ME)
+ ).toThrow(ChainActionRefused)
+ })
+})
+
+describe('streams this wallet started', () => {
+ it('lists what each can still bring back, with its id', async () => {
+ const { venue } = streamReader([
+ {},
+ { sender: STRANGER },
+ { token: USDT, refundable: BigInt(1_500_000) },
+ ])
+ await expect(venue.positions?.(ME)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '300', id: '1' },
+ { slot: 'supplied', symbol: 'USDT', amount: '1.5', id: '3' },
+ ])
+ })
+
+ it('leaves out ended streams, and a token anyone could have named this wallet on', async () => {
+ const { venue } = streamReader([
+ { status: 3, cancelable: false, refundable: BigInt(0) },
+ { status: 4, cancelable: false, refundable: BigInt(0) },
+ { token: STRANGER },
+ ])
+ await expect(venue.positions?.(ME)).resolves.toEqual([])
+ })
+
+ it('finds a stream past the first page of reads', async () => {
+ const streams: FakeStream[] = Array.from({ length: 501 }, () => ({ sender: STRANGER }))
+ streams[500] = {}
+ const { venue } = streamReader(streams)
+ await expect(venue.positions?.(ME)).resolves.toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '300', id: '501' },
+ ])
+ })
+
+ it.each(['nextStreamId', 'getSender', 'refundableAmountOf'])(
+ 'throws when %s can not be read, rather than saying there are none',
+ async (readFails) => {
+ await expect(streamReader([{}], { readFails }).venue.positions?.(ME)).rejects.toThrow()
+ }
+ )
+
+ it('throws rather than read only some of too many streams', async () => {
+ const { venue } = streamReader([], { nextOverride: BigInt(5_001) })
+ await expect(venue.positions?.(ME)).rejects.toThrow(/Too many/)
+ })
+
+ it('reads only ids that exist, starting from 1', async () => {
+ await expect(streamReader([]).venue.positions?.(ME)).resolves.toEqual([])
+ })
+})
+
+describe('the venues and their listing', () => {
+ it('offers streams and ending them, and joins no race', () => {
+ for (const venue of [sablierAvalancheVenue, sablierMonadVenue]) {
+ expect(venue.kinds).toEqual(['stream', 'cancel'])
+ expect(venue.quote).toBeUndefined()
+ }
+ })
+
+ it('lists one executing entry per chain, each with its own logo', () => {
+ expect(sablierVenues.map((v) => [v.id, v.family, v.chains, v.integration])).toEqual([
+ ['sablier-avalanche', 'evm', ['Avalanche'], 'executes'],
+ ['sablier-monad', 'evm', ['Monad'], 'executes'],
+ ])
+ for (const venue of sablierVenues) {
+ expect(venue.capability).toBeTruthy()
+ expect(
+ existsSync(join(process.cwd(), 'public', 'images', 'venues', `${venue.id}.webp`))
+ ).toBe(true)
+ }
+ })
+})
diff --git a/apps/dapp/lib/__tests__/scallop.live.test.ts b/apps/dapp/lib/__tests__/scallop.live.test.ts
new file mode 100644
index 00000000..aea96b40
--- /dev/null
+++ b/apps/dapp/lib/__tests__/scallop.live.test.ts
@@ -0,0 +1,266 @@
+import { describe, expect, it } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import { dryRunChange } from '../chains/sui/lending-calls'
+import {
+ chainObligations,
+ scallopBorrowTransaction,
+ scallopPriceTargets,
+ SCALLOP_BALANCE_SHEETS,
+ SCALLOP_BORROW_DYNAMICS,
+ SCALLOP_DECIMALS_REGISTRY,
+ SCALLOP_INTEREST_MODELS,
+ SCALLOP_MARKET,
+ SCALLOP_ORIGINAL_PACKAGE,
+ SCALLOP_PACKAGE,
+ SCALLOP_PYTH_REGISTRY,
+ SCALLOP_PYTH_RULE,
+ SCALLOP_RECEIPT_TYPE,
+ SCALLOP_RISK_MODELS,
+ SCALLOP_UPGRADE_CAP,
+ SCALLOP_VERSION,
+ SCALLOP_X_ORACLE,
+ scallopVenue,
+} from '../chains/sui/scallop'
+import { SUI_TOKENS } from '../chains/sui/tokens'
+
+/**
+ * Real transactions built and dry-run against mainnet, for public addresses whose keys
+ * nobody here has. Nothing signs or sends. If a holder moves its funds these fail on
+ * balance, so swap it for another that holds USDC receipts.
+ */
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const USDC_HOLDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const RECEIPT_HOLDER = '0xbe3fd04e98d2a2603122a3d2f7459e5ecef01768f1dc40cded8203995305203f'
+const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id ?? ''
+/** WAL against a USDC loan, in a position not staked for rewards. */
+const BORROWER = '0x7f4d16a5041815049a30265be7b5afa7909166cb15df97c18b97f31f7f116f39'
+/** SUI against USDT and USDY, in a position not staked for rewards. */
+const ROOMY = '0xcc31ad38af0687632c71b61ccf4277d370d8077ac6aa64ba6e02d40a4c7b98c6'
+/** A loan staked for Scallop's borrow rewards, as nearly all are. */
+const STAKED_BORROWER = '0xf181e1bfde0068006af48b6b564fa169f482476288a141c71fefbd49ba0c0b54'
+const NEVER_USED = '0x0000000000000000000000000000000000000000000000000000000000001234'
+
+describe.skipIf(SKIP)('Scallop against mainnet', () => {
+ it('still has the pinned package as its latest, or the allowlist needs a fresh look', async () => {
+ const { object } = await suiClient().core.getObject({
+ objectId: SCALLOP_UPGRADE_CAP,
+ include: { json: true },
+ })
+ expect((object.json as { package?: string }).package).toBe(SCALLOP_PACKAGE)
+ }, 30_000)
+
+ it('still has the pinned market and version objects', async () => {
+ const { objects } = await suiClient().core.getObjects({
+ objectIds: [SCALLOP_MARKET, SCALLOP_VERSION],
+ })
+ const [market, version] = objects as unknown as [{ type?: string }, { type?: string }]
+ expect(market.type).toBe(`${SCALLOP_ORIGINAL_PACKAGE}::market::Market`)
+ expect(version.type).toBe(`${SCALLOP_ORIGINAL_PACKAGE}::version::Version`)
+ }, 30_000)
+
+ it('builds a supply with a rate, passes its check and dry-runs clean', async () => {
+ const prepared = await scallopVenue.prepare(
+ { kind: 'supply', amount: '1', tokenIn: 'USDC' },
+ USDC_HOLDER
+ )
+ expect(prepared.apy?.basePercent).toBeGreaterThan(0)
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ expect(prepared.notes.join(' ')).toMatch(/\$142K/)
+ expect(() => scallopVenue.assertSafe(prepared.signing, USDC_HOLDER)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const minted = await dryRunChange(
+ prepared.signing.transaction,
+ SCALLOP_RECEIPT_TYPE,
+ USDC_HOLDER,
+ 'Dry run'
+ )
+ expect(Number(minted)).toBeGreaterThan(0)
+ }, 60_000)
+
+ it('builds a withdrawal and pays what was asked', async () => {
+ const prepared = await scallopVenue.prepare(
+ { kind: 'withdraw', amount: '1', tokenIn: 'USDC' },
+ RECEIPT_HOLDER
+ )
+ expect(() => scallopVenue.assertSafe(prepared.signing, RECEIPT_HOLDER)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const received = await dryRunChange(
+ prepared.signing.transaction,
+ USDC,
+ RECEIPT_HOLDER,
+ 'Dry run'
+ )
+ expect(Number(received)).toBeGreaterThanOrEqual(999_900)
+ }, 60_000)
+
+ it('refuses a wallet with no receipts', async () => {
+ await expect(
+ scallopVenue.prepare({ kind: 'withdraw', amount: '1', tokenIn: 'USDC' }, USDC_HOLDER)
+ ).rejects.toThrow(/no Scallop USDC receipts/)
+ }, 30_000)
+
+ it('still has the pinned oracle objects, and the rule the market fills prices with', async () => {
+ const { objects } = await suiClient().core.getObjects({
+ objectIds: [SCALLOP_X_ORACLE, SCALLOP_PYTH_REGISTRY, SCALLOP_DECIMALS_REGISTRY],
+ })
+ const [oracle, registry, decimals] = objects as unknown as [
+ { type?: string },
+ { type?: string },
+ { type?: string },
+ ]
+ expect(oracle.type).toContain('::x_oracle::XOracle')
+ expect(registry.type).toBe(`${SCALLOP_PYTH_RULE}::pyth_registry::PythRegistry`)
+ expect(decimals.type).toContain('::coin_decimals_registry::CoinDecimalsRegistry')
+ }, 30_000)
+
+ it('still has its table ids on the market object', async () => {
+ const { object } = await suiClient().core.getObject({
+ objectId: SCALLOP_MARKET,
+ include: { json: true },
+ })
+ const json = object.json as {
+ risk_models: { table: { id: string } }
+ interest_models: { table: { id: string } }
+ borrow_dynamics: { table: { id: string } }
+ vault: { balance_sheets: { table: { id: string } } }
+ }
+ expect(json.risk_models.table.id).toBe(SCALLOP_RISK_MODELS)
+ expect(json.interest_models.table.id).toBe(SCALLOP_INTEREST_MODELS)
+ expect(json.borrow_dynamics.table.id).toBe(SCALLOP_BORROW_DYNAMICS)
+ expect(json.vault.balance_sheets.table.id).toBe(SCALLOP_BALANCE_SHEETS)
+ }, 30_000)
+
+ it('reads a position with its loan and its receipts, and none for a wallet with none', async () => {
+ const held = (await scallopVenue.positions?.(BORROWER)) ?? []
+ expect(held.find((p) => p.slot === 'supplied' && p.symbol === 'WAL')).toBeDefined()
+ expect(
+ Number(held.find((p) => p.slot === 'borrowed' && p.symbol === 'USDC')?.amount)
+ ).toBeGreaterThan(0)
+
+ const receipts = (await scallopVenue.positions?.(RECEIPT_HOLDER)) ?? []
+ expect(receipts.find((p) => p.slot === 'supplied' && p.symbol === 'USDC')).toBeDefined()
+
+ expect(await scallopVenue.positions?.(NEVER_USED)).toEqual([])
+ }, 30_000)
+
+ it('reads a staked loan too, though it can only be repaid on Scallop', async () => {
+ const held = (await scallopVenue.positions?.(STAKED_BORROWER)) ?? []
+ expect(held.find((p) => p.slot === 'borrowed')).toBeDefined()
+ const owed = held.find((p) => p.slot === 'borrowed')
+ await expect(
+ scallopVenue.prepare(
+ { kind: 'repay', amount: '1', tokenIn: owed?.symbol ?? 'USDC' },
+ STAKED_BORROWER
+ )
+ ).rejects.toThrow(/Repay at scallop\.io/)
+ }, 30_000)
+
+ it('prices a position and reaches the borrow check with no key and no price service', async () => {
+ // Built directly: this is the only unstaked loan found, and the app stops it for its health.
+ // It sits at its limit, so the contract either pays or aborts on that limit (0x501) as the
+ // price of WAL moves. Either way the prices were accepted, which is what this is here to show.
+ const [position] = await chainObligations(BORROWER)
+ if (position === undefined) throw new Error('this wallet no longer has a position')
+ const tx = scallopBorrowTransaction(
+ BORROWER,
+ USDC,
+ BigInt(50_000),
+ position,
+ await scallopPriceTargets(position, [USDC])
+ )
+ const simulated = await suiClient().core.simulateTransaction({
+ transaction: tx,
+ checksEnabled: false,
+ include: { effects: true, balanceChanges: true },
+ })
+ if (simulated.$kind === 'Transaction') {
+ const paid = simulated.Transaction.balanceChanges.find((c) => c.coinType.endsWith('::USDC'))
+ expect(Number(paid?.amount)).toBeGreaterThan(49_000)
+ expect(Number(paid?.amount)).toBeLessThanOrEqual(50_000)
+ } else {
+ const error = simulated.FailedTransaction.effects.status.error
+ expect(error?.message).toContain('borrow_internal')
+ expect(error?.message).toContain('abort code: 1281')
+ }
+ }, 60_000)
+
+ it('repays part of a loan, proving the key, and takes exactly what was asked', async () => {
+ const prepared = await scallopVenue.prepare(
+ { kind: 'repay', amount: '0.5', tokenIn: 'USDC' },
+ BORROWER
+ )
+ expect(() => scallopVenue.assertSafe(prepared.signing, BORROWER)).not.toThrow()
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(await dryRunChange(prepared.signing.transaction, USDC, BORROWER, 'Dry run')).toBe(
+ BigInt(-500_000)
+ )
+ }, 60_000)
+
+ it('refuses a whole repay the wallet cannot cover', async () => {
+ const owed = ((await scallopVenue.positions?.(BORROWER)) ?? []).find(
+ (p) => p.slot === 'borrowed' && p.symbol === 'USDC'
+ )
+ if (owed === undefined) throw new Error('this wallet no longer owes USDC')
+ await expect(
+ scallopVenue.prepare(
+ { kind: 'repay', amount: owed.amount, tokenIn: 'USDC', wholePosition: true },
+ BORROWER
+ )
+ ).rejects.toThrow(/which is less than the .* you owe/)
+ }, 60_000)
+
+ it('stops that same borrow for the room it leaves before liquidation', async () => {
+ await expect(
+ scallopVenue.prepare({ kind: 'borrow', amount: '0.2', tokenIn: 'USDC' }, BORROWER)
+ ).rejects.toThrow(/health factor at|can back about|can't back any more/)
+ }, 60_000)
+
+ it('refuses a borrow past what the collateral can back', async () => {
+ await expect(
+ scallopVenue.prepare({ kind: 'borrow', amount: '100000', tokenIn: 'USDC' }, BORROWER)
+ ).rejects.toThrow(/can back about|can't back any more/)
+ }, 60_000)
+
+ it('says so when a position holds a coin whose price others do not keep current', async () => {
+ await expect(
+ scallopVenue.prepare({ kind: 'borrow', amount: '0.01', tokenIn: 'USDT' }, ROOMY)
+ ).rejects.toThrow(/prices from the last 30 seconds|health factor at|can back about/)
+ }, 60_000)
+
+ it('refuses taking out what a loan depends on, and says so', async () => {
+ const held = ((await scallopVenue.positions?.(BORROWER)) ?? []).find(
+ (p) => p.slot === 'supplied' && p.symbol === 'WAL'
+ )
+ if (held === undefined) throw new Error('this wallet no longer supplies WAL')
+ await expect(
+ scallopVenue.prepare(
+ { kind: 'withdraw', amount: held.amount, tokenIn: 'WAL', wholePosition: true },
+ BORROWER
+ )
+ ).rejects.toThrow(/Your loan on Scallop depends on this WAL/)
+ }, 60_000)
+
+ it('empties a holding of receipts on a whole withdraw', async () => {
+ const held = ((await scallopVenue.positions?.(RECEIPT_HOLDER)) ?? []).find(
+ (p) => p.slot === 'supplied' && p.symbol === 'USDC'
+ )
+ if (held === undefined) throw new Error('this wallet no longer holds receipts')
+ const prepared = await scallopVenue.prepare(
+ { kind: 'withdraw', amount: held.amount, tokenIn: 'USDC', wholePosition: true },
+ RECEIPT_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const receipts = await dryRunChange(
+ prepared.signing.transaction,
+ SCALLOP_RECEIPT_TYPE,
+ RECEIPT_HOLDER,
+ 'Dry run'
+ )
+ const { balance } = await suiClient().core.getBalance({
+ owner: RECEIPT_HOLDER,
+ coinType: SCALLOP_RECEIPT_TYPE,
+ })
+ expect(receipts).toBe(-BigInt(balance.balance))
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/scallop.test.ts b/apps/dapp/lib/__tests__/scallop.test.ts
new file mode 100644
index 00000000..a97ebfc7
--- /dev/null
+++ b/apps/dapp/lib/__tests__/scallop.test.ts
@@ -0,0 +1,578 @@
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, toBase64 } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { coinTypeName } from '../chains/sui/lending-calls'
+import {
+ accruedPool,
+ amountForReceipts,
+ createScallopVenue,
+ receiptsForAmount,
+ scallopBorrowApr,
+ scallopBorrowTransaction,
+ scallopRepayTransaction,
+ scallopSupplyApr,
+ scallopVenue,
+ scallopWithdrawTransaction,
+ type ScallopObligation,
+ type ScallopPool,
+ type ScallopReader,
+} from '../chains/sui/scallop'
+import { scallopListing } from '../chains/sui/scallop-listing'
+import { SUI_TOKENS } from '../chains/sui/tokens'
+import { ChainActionRefused, type ChainActionRequest, type SigningPayload } from '../chains/types'
+import {
+ callNamed,
+ inputOf,
+ OTHER_ADDRESS,
+ OTHER_OBJECT,
+ payTo,
+ pointAt,
+ SCALLOP_BORROW,
+ SCALLOP_BORROW_SENDER,
+ SCALLOP_REPAY,
+ SCALLOP_REPAY_SENDER,
+ SCALLOP_SUPPLY,
+ SCALLOP_SUPPLY_SENDER,
+ SCALLOP_WITHDRAW,
+ SCALLOP_WITHDRAW_SENDER,
+ tampered,
+} from './sui-lending-captured'
+
+const USDT = '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT'
+const UNLISTED = '0x0000000000000000000000000000000000000000000000000000000000000bad::fake::FAKE'
+const ME = '0x0000000000000000000000000000000000000000000000000000000000000abc'
+const USDC_TYPE = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id ?? ''
+const USDC_NAME = coinTypeName(USDC_TYPE)
+const SUI_NAME = coinTypeName('0x2::sui::SUI')
+const OBLIGATION = '0x0000000000000000000000000000000000000000000000000000000000000b01'
+const KEY = '0x0000000000000000000000000000000000000000000000000000000000000b02'
+
+function checkSupply(transaction: string, account = SCALLOP_SUPPLY_SENDER): void {
+ scallopVenue.assertSafe({ family: 'sui', transaction }, account)
+}
+
+function checkWithdraw(transaction: string, account = SCALLOP_WITHDRAW_SENDER): void {
+ scallopVenue.assertSafe({ family: 'sui', transaction }, account)
+}
+
+/** The USDC pool as read on 2026-11-05. */
+const POOL: ScallopPool = {
+ cash: BigInt('408594658931'),
+ debt: BigInt('1148010447929'),
+ revenue: BigInt('3217384834'),
+ receiptSupply: BigInt('1358202340967'),
+ borrowRate: BigInt('123747030'),
+ borrowRateScale: BigInt('10000000'),
+ revenueFactor: BigInt('858993459'),
+ updatedSeconds: 0,
+}
+
+function obligation(over: Partial = {}): ScallopObligation {
+ return {
+ id: OBLIGATION,
+ keyId: KEY,
+ collaterals: [{ coinName: SUI_NAME, amount: BigInt(10_000_000_000) }],
+ debts: [],
+ borrowLocked: false,
+ repayLocked: false,
+ withdrawLocked: false,
+ ...over,
+ }
+}
+
+function reader(
+ receipts: bigint,
+ pool: ScallopPool = POOL,
+ over: Partial = {}
+): ScallopReader {
+ return {
+ pool: async () => pool,
+ receipts: async () => receipts,
+ obligations: async () => [],
+ standing: async () => ({
+ liquidationUsd: 8,
+ limitUsd: 7,
+ owedUsd: 0,
+ readings: [
+ { objectId: OTHER_OBJECT, priceUsd: 1, emaUsd: 1, ageSeconds: 3 },
+ { objectId: OTHER_OBJECT, priceUsd: 1, emaUsd: 1, ageSeconds: 3 },
+ ],
+ }),
+ weights: async () => ({ collateralFactor: 0.7, liquidationFactor: 0.8 }),
+ borrowFeePercent: async () => 0,
+ walletBalance: async () => BigInt(50_000_000),
+ ...over,
+ }
+}
+
+function withdraw(amount = '1'): ChainActionRequest {
+ return { kind: 'withdraw', amount, tokenIn: 'USDC' }
+}
+
+function action(kind: ChainActionRequest['kind'], amount = '1', tokenIn = 'USDC') {
+ return { kind, amount, tokenIn } as ChainActionRequest
+}
+
+function literal(tx: Transaction, name: string, index: number): bigint {
+ const data = tx.getData()
+ const call = data.commands.find(
+ (c) => c.$kind === 'MoveCall' && c.MoveCall.function === name
+ )?.MoveCall
+ const argument = call?.arguments[index]
+ const input = argument?.$kind === 'Input' ? data.inputs[argument.Input] : undefined
+ if (input?.$kind !== 'Pure') throw new Error(`no literal at ${index} of ${name}`)
+ const bytes = fromBase64(input.Pure.bytes)
+ return new DataView(bytes.buffer, bytes.byteOffset).getBigUint64(0, true)
+}
+
+function calls(tx: Transaction): string[] {
+ return tx.getData().commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : []))
+}
+
+describe('what the venue really built passes', () => {
+ it('a supply and a withdrawal', () => {
+ expect(() => checkSupply(SCALLOP_SUPPLY)).not.toThrow()
+ expect(() => checkWithdraw(SCALLOP_WITHDRAW)).not.toThrow()
+ })
+
+ it('a borrow and a repay', () => {
+ expect(() => checkSupply(SCALLOP_BORROW, SCALLOP_BORROW_SENDER)).not.toThrow()
+ expect(() => checkSupply(SCALLOP_REPAY, SCALLOP_REPAY_SENDER)).not.toThrow()
+ })
+})
+
+describe('Scallop refuses a transaction that', () => {
+ it('another account would sign', () => {
+ expect(() => checkSupply(SCALLOP_SUPPLY, OTHER_ADDRESS)).toThrow(/different account/)
+ expect(() => checkWithdraw(SCALLOP_WITHDRAW, OTHER_ADDRESS)).toThrow(ChainActionRefused)
+ })
+
+ it('calls a contract that is not confirmed', async () => {
+ const tx = await tampered(SCALLOP_WITHDRAW, (t) => {
+ callNamed(t, 'redeem').package = OTHER_OBJECT
+ })
+ expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Scallop/)
+ })
+
+ it('calls the older package the docs still name', async () => {
+ const tx = await tampered(SCALLOP_WITHDRAW, (t) => {
+ callNamed(t, 'redeem').package =
+ '0xa45b8ffca59e5b44ec7c04481a04cb620b0e07b2b183527bca4e5f32372c5f1a'
+ })
+ expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Scallop/)
+ })
+
+ it.each(['redeem_entry', 'flash_loan', 'liquidate'])(
+ 'calls %s, which this venue never uses',
+ async (name) => {
+ const tx = await tampered(SCALLOP_WITHDRAW, (t) => {
+ callNamed(t, 'redeem').function = name
+ })
+ expect(() => checkWithdraw(tx)).toThrow(/haven't confirmed for Scallop/)
+ }
+ )
+
+ it('passes an extra argument', async () => {
+ const tx = await tampered(SCALLOP_WITHDRAW, (t) => {
+ const call = callNamed(t, 'redeem')
+ const extra = call.arguments[0]
+ if (extra === undefined) throw new Error('no argument')
+ call.arguments.push(extra)
+ })
+ expect(() => checkWithdraw(tx)).toThrow(/wrong arguments/)
+ })
+
+ it('takes an extra step of a kind it never needs', async () => {
+ const base = Transaction.from(SCALLOP_SUPPLY)
+ base.makeMoveVec({ type: 'u64', elements: [] })
+ const tx = toBase64(await base.build())
+ expect(() => checkSupply(tx)).toThrow(/does more than Scallop needs/)
+ })
+
+ it('moves anything through the framework', async () => {
+ const base = Transaction.from(SCALLOP_WITHDRAW)
+ base.moveCall({
+ target: '0x2::transfer::public_transfer',
+ typeArguments: ['0x2::sui::SUI'],
+ arguments: [base.gas, base.pure.address(OTHER_ADDRESS)],
+ })
+ const tx = toBase64(await base.build())
+ expect(() => checkWithdraw(tx)).toThrow(ChainActionRefused)
+ })
+
+ it('pays the withdrawal to someone else', async () => {
+ const tx = await tampered(SCALLOP_WITHDRAW, (t) => payTo(t, OTHER_ADDRESS))
+ expect(() => checkWithdraw(tx)).toThrow(/another address/)
+ })
+
+ it('sends the receipts to someone else', async () => {
+ const tx = await tampered(SCALLOP_SUPPLY, (t) => payTo(t, OTHER_ADDRESS))
+ expect(() => checkSupply(tx)).toThrow(/another address/)
+ })
+
+ it('supplies a token other than USDC', async () => {
+ const tx = await tampered(SCALLOP_SUPPLY, (t) => {
+ callNamed(t, 'mint').typeArguments = [USDT]
+ })
+ expect(() => checkSupply(tx)).toThrow(/token Scallop isn't confirmed for/)
+ })
+
+ it('redeems a token outside the list', async () => {
+ const tx = await tampered(SCALLOP_WITHDRAW, (t) => {
+ callNamed(t, 'redeem').typeArguments = [UNLISTED]
+ })
+ expect(() => checkWithdraw(tx)).toThrow(/token Scallop isn't confirmed for/)
+ })
+
+ it('points at another market or version object', async () => {
+ const market = await tampered(SCALLOP_WITHDRAW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'redeem'), 1), OTHER_OBJECT)
+ })
+ expect(() => checkWithdraw(market)).toThrow(/object Scallop doesn't use/)
+ const version = await tampered(SCALLOP_WITHDRAW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'redeem'), 0), OTHER_OBJECT)
+ })
+ expect(() => checkWithdraw(version)).toThrow(/object Scallop doesn't use/)
+ })
+
+ it('is not a Sui transaction, or not readable', () => {
+ const near: SigningPayload = { family: 'near', transactions: [] }
+ expect(() => scallopVenue.assertSafe(near, SCALLOP_SUPPLY_SENDER)).toThrow(/not a Sui/)
+ expect(() => checkSupply('not base64!')).toThrow(ChainActionRefused)
+ })
+
+ it('sends a borrow to someone else', async () => {
+ const tx = await tampered(SCALLOP_BORROW, (t) => payTo(t, OTHER_ADDRESS))
+ expect(() => checkSupply(tx, SCALLOP_BORROW_SENDER)).toThrow(/another address/)
+ })
+
+ it('borrows through another oracle or market', async () => {
+ const oracle = await tampered(SCALLOP_BORROW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'borrow'), 6), OTHER_OBJECT)
+ })
+ expect(() => checkSupply(oracle, SCALLOP_BORROW_SENDER)).toThrow(/object Scallop doesn't use/)
+ const market = await tampered(SCALLOP_BORROW, (t) => {
+ pointAt(inputOf(t, callNamed(t, 'borrow'), 3), OTHER_OBJECT)
+ })
+ expect(() => checkSupply(market, SCALLOP_BORROW_SENDER)).toThrow(/object Scallop doesn't use/)
+ })
+
+ it('reads a price by another rule', async () => {
+ const tx = await tampered(SCALLOP_BORROW, (t) => {
+ callNamed(t, 'set_price_as_primary').package = OTHER_OBJECT
+ })
+ expect(() => checkSupply(tx, SCALLOP_BORROW_SENDER)).toThrow(/haven't confirmed for Scallop/)
+ })
+
+ it("repays someone else's loan, while proving the key on its own", async () => {
+ const tx = await tampered(SCALLOP_REPAY, (t) => {
+ const repay = callNamed(t, 'repay')
+ const own = inputOf(t, repay, 1)
+ const stranger = JSON.parse(JSON.stringify(own)) as typeof own
+ pointAt(stranger, OTHER_OBJECT)
+ t.inputs.push(stranger)
+ repay.arguments[1] = { Input: t.inputs.length - 1 }
+ })
+ expect(() => checkSupply(tx, SCALLOP_REPAY_SENDER)).toThrow(/isn't shown to be yours/)
+ })
+
+ it('repays with no key check at all', async () => {
+ const tx = await tampered(SCALLOP_REPAY, (t) => {
+ callNamed(t, 'assert_key_match').function = 'is_key_match'
+ })
+ expect(() => checkSupply(tx, SCALLOP_REPAY_SENDER)).toThrow(/haven't confirmed for Scallop/)
+ })
+
+ it('names another token on a repay', async () => {
+ const tx = await tampered(SCALLOP_REPAY, (t) => {
+ callNamed(t, 'repay').typeArguments = [UNLISTED]
+ })
+ expect(() => checkSupply(tx, SCALLOP_REPAY_SENDER)).toThrow(/token Scallop isn't confirmed for/)
+ })
+})
+
+describe('amounts', () => {
+ it('matches what a dry run redeemed: 100000 receipts for about 114370', () => {
+ const value = amountForReceipts(BigInt(100_000), POOL)
+ expect(Number(value)).toBeGreaterThan(114_300)
+ expect(Number(value)).toBeLessThan(114_400)
+ })
+
+ it('rounds receipts up, and a whole holding converts back within itself', () => {
+ const held = BigInt('874346')
+ const value = amountForReceipts(held, POOL)
+ expect(receiptsForAmount(value, POOL) <= held).toBe(true)
+ expect(receiptsForAmount(BigInt(1_000_000), POOL) * BigInt(1) > BigInt(874_000)).toBe(true)
+ })
+
+ it('reads the rate from the contract, with Scallop’s share taken off', () => {
+ expect(scallopSupplyApr(POOL)).toBeCloseTo(5.37, 1)
+ expect(scallopBorrowApr(POOL)).toBeCloseTo(9.09, 0)
+ })
+
+ it('shows no rate when it cannot be worked out', () => {
+ expect(scallopSupplyApr({ ...POOL, borrowRateScale: BigInt(0) })).toBeUndefined()
+ expect(scallopSupplyApr({ ...POOL, debt: BigInt(0), cash: BigInt(0) })).toBeUndefined()
+ expect(scallopBorrowApr({ ...POOL, borrowRateScale: BigInt(0) })).toBeUndefined()
+ })
+
+ it('refuses a pool it cannot value rather than guess', () => {
+ const empty = { ...POOL, receiptSupply: BigInt(0) }
+ expect(() => receiptsForAmount(BigInt(1), empty)).toThrow(/couldn't read Scallop/)
+ expect(() => amountForReceipts(BigInt(1), empty)).toThrow(ChainActionRefused)
+ })
+
+ it('adds interest since the pool last moved, so receipts are worth what they are now', () => {
+ const later = accruedPool({ ...POOL, updatedSeconds: 1000 }, 1000 + 86_400)
+ expect(later.debt).toBeGreaterThan(POOL.debt)
+ expect(amountForReceipts(BigInt(100_000), later)).toBeGreaterThan(
+ amountForReceipts(BigInt(100_000), POOL)
+ )
+ })
+})
+
+describe('the transactions it builds', () => {
+ const held = { id: OBLIGATION, keyId: KEY }
+ const prices = [{ coinType: USDC_TYPE, priceObject: OTHER_OBJECT }]
+
+ it('borrow exactly what was asked, and price each coin first', () => {
+ const tx = scallopBorrowTransaction(ME, USDC_TYPE, BigInt(1_500_000), held, prices)
+ expect(literal(tx, 'borrow', 5)).toBe(BigInt(1_500_000))
+ expect(calls(tx)).toEqual([
+ 'price_update_request',
+ 'set_price_as_primary',
+ 'confirm_price_update_request',
+ 'borrow',
+ ])
+ const transfers = tx.getData().commands.filter((c) => c.$kind === 'TransferObjects')
+ expect(transfers).toHaveLength(1)
+ })
+
+ it('check the key before each repay, with no price needed', () => {
+ const tx = scallopRepayTransaction(ME, USDC_TYPE, [{ obligation: held, coin: BigInt(5) }])
+ expect(calls(tx)).toEqual(['assert_key_match', 'repay'])
+ })
+
+ it('hand back every receipt on a whole withdraw, and take collateral too', () => {
+ const tx = scallopWithdrawTransaction(
+ ME,
+ USDC_TYPE,
+ BigInt(874_346),
+ [{ obligation: obligation(), amount: BigInt(7) }],
+ prices
+ )
+ expect(calls(tx)).toEqual([
+ 'redeem',
+ 'price_update_request',
+ 'set_price_as_primary',
+ 'confirm_price_update_request',
+ 'withdraw_collateral',
+ ])
+ expect(literal(tx, 'withdraw_collateral', 5)).toBe(BigInt(7))
+ })
+
+ it('touch no collateral when receipts cover it', () => {
+ const tx = scallopWithdrawTransaction(ME, USDC_TYPE, BigInt(10), [], [])
+ expect(calls(tx)).toEqual(['redeem'])
+ })
+})
+
+describe('prepare refuses before anything is built', () => {
+ it('a token outside Scallop’s list, and a supply of anything but USDC', async () => {
+ const venue = createScallopVenue(reader(BigInt(0)))
+ await expect(
+ venue.prepare({ kind: 'supply', amount: '1', tokenIn: 'SUI' }, ME)
+ ).rejects.toThrow(/takes USDC here, not SUI/)
+ await expect(venue.prepare(action('borrow', '1', 'sSUI'), ME)).rejects.toThrow(
+ /doesn't take sSUI/
+ )
+ await expect(venue.prepare(action('withdraw', '1', 'sSUI'), ME)).rejects.toThrow(
+ /doesn't take sSUI/
+ )
+ })
+
+ it('a malformed account', async () => {
+ await expect(createScallopVenue(reader(BigInt(1))).prepare(withdraw(), 'nope')).rejects.toThrow(
+ /not a Sui address/
+ )
+ })
+
+ it('a withdrawal with no receipts, and says where such a deposit can be', async () => {
+ await expect(createScallopVenue(reader(BigInt(0))).prepare(withdraw(), ME)).rejects.toThrow(
+ /no Scallop USDC receipts.*scallop\.io/
+ )
+ })
+
+ it('more than the receipts are worth, and names what they are', async () => {
+ await expect(
+ createScallopVenue(reader(BigInt(1_000_000))).prepare(withdraw('5'), ME)
+ ).rejects.toThrow(/worth about 1\.14\d+ USDC, which is less than 5/)
+ })
+
+ it('collateral a loan depends on, in plain words', async () => {
+ const loaned = obligation({
+ collaterals: [{ coinName: USDC_NAME, amount: BigInt(10_000_000) }],
+ debts: [{ coinName: SUI_NAME, amount: BigInt(1_000_000_000) }],
+ })
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, {
+ obligations: async () => [loaned],
+ standing: async () => ({
+ liquidationUsd: 8,
+ limitUsd: 7,
+ owedUsd: 5,
+ readings: [
+ { objectId: OTHER_OBJECT, priceUsd: 1, emaUsd: 1, ageSeconds: 3 },
+ { objectId: OTHER_OBJECT, priceUsd: 1, emaUsd: 1, ageSeconds: 3 },
+ ],
+ }),
+ })
+ )
+ await expect(venue.prepare(withdraw('10'), ME)).rejects.toThrow(
+ /Your loan on Scallop depends on this USDC/
+ )
+ })
+
+ it('a position locked for its rewards', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, {
+ obligations: async () => [
+ obligation({
+ collaterals: [{ coinName: USDC_NAME, amount: BigInt(5_000_000) }],
+ withdrawLocked: true,
+ }),
+ ],
+ })
+ )
+ await expect(venue.prepare(withdraw('1'), ME)).rejects.toThrow(/locked for its rewards/)
+ })
+
+ it('a borrow with no borrowing position, saying receipts do not count', async () => {
+ await expect(
+ createScallopVenue(reader(BigInt(5_000_000))).prepare(action('borrow'), ME)
+ ).rejects.toThrow(/no collateral in a Scallop borrowing position.*receipts/)
+ })
+
+ it('a borrow from a position staked for rewards', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, { obligations: async () => [obligation({ borrowLocked: true })] })
+ )
+ await expect(venue.prepare(action('borrow'), ME)).rejects.toThrow(
+ /staked for its borrow rewards/
+ )
+ })
+
+ it('a borrow of what the position holds as collateral', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, {
+ obligations: async () => [
+ obligation({ collaterals: [{ coinName: USDC_NAME, amount: BigInt(5) }] }),
+ ],
+ })
+ )
+ await expect(venue.prepare(action('borrow'), ME)).rejects.toThrow(
+ /won't lend USDC against a position that holds USDC/
+ )
+ })
+
+ it('a borrow past what the collateral can back', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, { obligations: async () => [obligation()] })
+ )
+ await expect(venue.prepare(action('borrow', '100'), ME)).rejects.toThrow(
+ /can back about 7 more/
+ )
+ })
+
+ it('a repay of a token not owed', async () => {
+ await expect(
+ createScallopVenue(reader(BigInt(0))).prepare(action('repay'), ME)
+ ).rejects.toThrow(/don't owe any USDC on Scallop/)
+ })
+
+ it('a repay of a loan staked for rewards', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, {
+ obligations: async () => [
+ obligation({
+ debts: [{ coinName: USDC_NAME, amount: BigInt(1_000_000) }],
+ repayLocked: true,
+ }),
+ ],
+ })
+ )
+ await expect(venue.prepare(action('repay'), ME)).rejects.toThrow(/Repay at scallop\.io/)
+ })
+
+ it('a whole repay the wallet cannot cover', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(0), POOL, {
+ walletBalance: async () => BigInt(1),
+ obligations: async () => [
+ obligation({ debts: [{ coinName: USDC_NAME, amount: BigInt(1_000_000) }] }),
+ ],
+ })
+ )
+ await expect(venue.prepare({ ...action('repay'), wholePosition: true }, ME)).rejects.toThrow(
+ /which is less than the 1 you owe/
+ )
+ })
+})
+
+describe('positions', () => {
+ it('adds receipts and collateral, and reports what is owed', async () => {
+ const venue = createScallopVenue(
+ reader(BigInt(1_000_000), POOL, {
+ receipts: async (_account, coinType) =>
+ coinType === USDC_TYPE ? BigInt(1_000_000) : BigInt(0),
+ obligations: async () => [
+ obligation({
+ collaterals: [{ coinName: USDC_NAME, amount: BigInt(2_000_000) }],
+ debts: [{ coinName: SUI_NAME, amount: BigInt(3_000_000_000) }],
+ }),
+ ],
+ })
+ )
+ const held = (await venue.positions?.(ME)) ?? []
+ const usdc = held.find((p) => p.slot === 'supplied' && p.symbol === 'USDC')
+ expect(Number(usdc?.amount)).toBeGreaterThan(3.14)
+ expect(Number(usdc?.amount)).toBeLessThan(3.15)
+ expect(held.find((p) => p.slot === 'borrowed' && p.symbol === 'SUI')?.amount).toBe('3')
+ })
+
+ it('is empty for a wallet with nothing there', async () => {
+ expect(await createScallopVenue(reader(BigInt(0))).positions?.(ME)).toEqual([])
+ })
+
+ it('throws when it cannot read, and never reads as empty', async () => {
+ const down = reader(BigInt(0), POOL, {
+ obligations: async () => {
+ throw new Error('rpc down')
+ },
+ })
+ await expect(createScallopVenue(down).positions?.(ME)).rejects.toThrow('rpc down')
+ })
+})
+
+describe('the venue', () => {
+ it('supplies, withdraws, borrows and repays', () => {
+ expect(scallopVenue.kinds).toEqual(['supply', 'withdraw', 'borrow', 'repay'])
+ })
+
+ it('quotes a base rate from the reader alone', async () => {
+ const quote = await createScallopVenue(reader(BigInt(0))).quote?.(
+ { kind: 'supply', amount: '1', tokenIn: 'USDC' },
+ ME
+ )
+ expect(quote?.apyPercent).toBeCloseTo(5.37, 1)
+ })
+
+ it('quotes no rate on a loan, where a saving rate would mislead', async () => {
+ expect(await createScallopVenue(reader(BigInt(0))).quote?.(action('borrow'), ME)).toEqual({})
+ })
+
+ it('has a listing that says it executes', () => {
+ expect(scallopListing).toMatchObject({ id: 'scallop', family: 'sui', integration: 'executes' })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/solana-exits.live.test.ts b/apps/dapp/lib/__tests__/solana-exits.live.test.ts
new file mode 100644
index 00000000..d1e97ad4
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-exits.live.test.ts
@@ -0,0 +1,246 @@
+import { getCompiledTransactionMessageDecoder, getTransactionDecoder } from '@solana/kit'
+import { describe, expect, it } from 'vitest'
+
+import {
+ KAMINO_HOLDER,
+ LULO_HOLDER,
+ RESERVE_HOLDER,
+} from '../chains/solana/__fixtures__/captured-exits'
+import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import { createJupiterLendVenue } from '../chains/solana/jupiter-lend'
+import { createKaminoVenue } from '../chains/solana/kamino'
+import { createLuloVenue } from '../chains/solana/lulo'
+import { JUPITER_LEND_PROGRAM } from '../chains/solana/programs'
+import type { ChainVenue } from '../chains/types'
+
+/**
+ * Builds each exit for a real wallet holding the position, runs the venue's check on it, and
+ * simulates it on mainnet to see what would land. Read-only: nothing here signs or sends, and the
+ * holders are public wallets found on chain. A holder who has since left skips with a warning.
+ *
+ * Lulo's docs require a key, but on 5 November 2026 its API answered without one, so this runs with
+ * `LULO_API_KEY` if set and with an empty key if not. It fails if Lulo starts enforcing that.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 90_000
+const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const JUPITER_HOLDER = 'HFqp6ErWHY6Uzhj8rFyjYuDya2mXUpYEk8VW75K9PSiY'
+
+const RPCS = [
+ process.env['SOLANA_RPC_URL']?.trim() || 'https://solana-rpc.publicnode.com',
+ 'https://api.mainnet-beta.solana.com',
+]
+
+async function rpc(method: string, params: unknown[]): Promise {
+ let last = ''
+ for (let attempt = 0; attempt < 6; attempt += 1) {
+ const url = RPCS[attempt % RPCS.length] as string
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
+ signal: AbortSignal.timeout(20_000),
+ })
+ const body = (await response.json()) as { result?: unknown; error?: unknown }
+ if (body.error === undefined && body.result !== undefined) return body.result
+ last = JSON.stringify(body.error)
+ } catch (e) {
+ last = String(e)
+ }
+ await new Promise((resolve) => setTimeout(resolve, 2_000))
+ }
+ throw new Error(`${method} failed: ${last}`)
+}
+
+const amountIn = (base64: string | undefined): bigint =>
+ base64 === undefined ? BigInt(0) : Buffer.from(base64, 'base64').readBigUInt64LE(64)
+
+/** What a transaction would pay into the wallet's USDC account, simulated on mainnet. */
+async function simulatedPayout(transaction: string, holder: string): Promise {
+ const recipient = associatedTokenAccount(holder, USDC)
+ const before = (await rpc('getMultipleAccounts', [
+ [recipient],
+ { encoding: 'base64', commitment: 'confirmed' },
+ ])) as { value: ({ data: [string, string] } | null)[] }
+ const simulated = (await rpc('simulateTransaction', [
+ transaction,
+ {
+ encoding: 'base64',
+ sigVerify: false,
+ replaceRecentBlockhash: true,
+ commitment: 'confirmed',
+ accounts: { encoding: 'base64', addresses: [recipient] },
+ },
+ ])) as {
+ value: {
+ err: unknown
+ logs?: string[]
+ accounts?: ({ data: [string, string] } | null)[]
+ }
+ }
+ expect(simulated.value.err, (simulated.value.logs ?? []).slice(-6).join('\n')).toBeNull()
+ return amountIn(simulated.value.accounts?.[0]?.data[0]) - amountIn(before.value[0]?.data[0])
+}
+
+async function held(venue: ChainVenue, holder: string): Promise {
+ const [position] = (await venue.positions?.(holder)) ?? []
+ if (position === undefined) console.warn(`${holder} no longer holds this position; skipping.`)
+ return position?.amount
+}
+
+const toUnits = (display: string): number => Math.round(Number(display) * 1_000_000)
+
+describe.skipIf(SKIP)('jupiter lend exit on mainnet', () => {
+ const venue = createJupiterLendVenue()
+
+ it(
+ 'reads the position at the rate Jupiter reports',
+ async () => {
+ const amount = await held(venue, JUPITER_HOLDER)
+ if (amount === undefined) return
+ const response = await fetch(
+ `https://api.jup.ag/lend/v1/earn/positions?users=${JUPITER_HOLDER}`
+ )
+ const list = (await response.json()) as {
+ token?: { assetAddress?: string }
+ underlyingAssets?: string
+ }[]
+ const theirs = list.find((p) => p.token?.assetAddress === USDC)?.underlyingAssets
+ expect(Number(theirs)).toBeGreaterThan(0)
+ expect(toUnits(amount) / Number(theirs)).toBeCloseTo(1, 3)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'builds a withdrawal with exactly the accounts Jupiter’s own API uses, from the chain alone',
+ async () => {
+ const amount = await held(venue, JUPITER_HOLDER)
+ if (amount === undefined) return
+ const prepared = await venue.prepare(
+ { kind: 'withdraw', amount: '0.5', tokenIn: 'USDC' },
+ JUPITER_HOLDER
+ )
+ venue.assertSafe(prepared.signing, JUPITER_HOLDER)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+
+ const response = await fetch('https://api.jup.ag/lend/v1/earn/withdraw', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ asset: USDC, signer: JUPITER_HOLDER, amount: '500000' }),
+ })
+ const { transaction } = (await response.json()) as { transaction: string }
+ const accountsOf = (base64: string): readonly string[] => {
+ const decoded = decodeSolanaTransaction(base64)
+ const instruction = decoded.instructions.find(
+ (i) => decoded.staticAccounts[i.programIndex] === JUPITER_LEND_PROGRAM
+ )
+ return (instruction?.accounts ?? []).map((i) => decoded.staticAccounts[i] as string)
+ }
+ expect(accountsOf(prepared.signing.transaction)).toEqual(accountsOf(transaction))
+ const message = getCompiledTransactionMessageDecoder().decode(
+ getTransactionDecoder().decode(Buffer.from(prepared.signing.transaction, 'base64'))
+ .messageBytes
+ )
+ expect(message.version).toBe('legacy')
+
+ expect(await simulatedPayout(prepared.signing.transaction, JUPITER_HOLDER)).toBe(
+ BigInt(500_000)
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'empties the whole position in simulation',
+ async () => {
+ const amount = await held(venue, JUPITER_HOLDER)
+ if (amount === undefined) return
+ const prepared = await venue.prepare(
+ { kind: 'withdraw', amount, tokenIn: 'USDC', wholePosition: true },
+ JUPITER_HOLDER
+ )
+ venue.assertSafe(prepared.signing, JUPITER_HOLDER)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ const paid = await simulatedPayout(prepared.signing.transaction, JUPITER_HOLDER)
+ expect(Number(paid) / toUnits(amount)).toBeCloseTo(1, 3)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('kamino exit on mainnet', () => {
+ const venue = createKaminoVenue()
+
+ it.each([
+ ['a vault with cash free', KAMINO_HOLDER],
+ ['a vault drawing on its lending markets', RESERVE_HOLDER],
+ ])(
+ 'builds part of a position from %s, and pays what the card says',
+ async (_, holder) => {
+ const amount = await held(venue, holder)
+ if (amount === undefined) return
+ const prepared = await venue.prepare(
+ { kind: 'withdraw', amount: '2', tokenIn: 'USDC' },
+ holder
+ )
+ venue.assertSafe(prepared.signing, holder)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ const paid = await simulatedPayout(prepared.signing.transaction, holder)
+ expect(paid).toBeGreaterThanOrEqual(BigInt(2_000_000))
+ expect(paid).toBeLessThan(BigInt(2_001_000))
+ },
+ SLOW_MS
+ )
+
+ it.each([
+ ['a vault with cash free', KAMINO_HOLDER],
+ ['a vault drawing on its lending markets', RESERVE_HOLDER],
+ ])(
+ 'empties the whole position from %s in simulation',
+ async (_, holder) => {
+ const amount = await held(venue, holder)
+ if (amount === undefined) return
+ const prepared = await venue.prepare(
+ { kind: 'withdraw', amount, tokenIn: 'USDC', wholePosition: true },
+ holder
+ )
+ venue.assertSafe(prepared.signing, holder)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ const paid = await simulatedPayout(prepared.signing.transaction, holder)
+ expect(Number(paid) / toUnits(amount)).toBeCloseTo(1, 3)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('lulo exit on mainnet', () => {
+ const venue = createLuloVenue({ apiKey: process.env['LULO_API_KEY'] ?? '' })
+
+ it(
+ 'builds part and all of a position, and each pays the holder in simulation',
+ async () => {
+ const amount = await held(venue, LULO_HOLDER)
+ if (amount === undefined) return
+ const part = await venue.prepare(
+ { kind: 'withdraw', amount: '1', tokenIn: 'USDC' },
+ LULO_HOLDER
+ )
+ venue.assertSafe(part.signing, LULO_HOLDER)
+ if (part.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ expect(await simulatedPayout(part.signing.transaction, LULO_HOLDER)).toBe(BigInt(1_000_000))
+
+ const whole = await venue.prepare(
+ { kind: 'withdraw', amount, tokenIn: 'USDC', wholePosition: true },
+ LULO_HOLDER
+ )
+ venue.assertSafe(whole.signing, LULO_HOLDER)
+ if (whole.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ const paid = await simulatedPayout(whole.signing.transaction, LULO_HOLDER)
+ expect(Number(paid) / toUnits(amount)).toBeCloseTo(1, 3)
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/solana-exits.test.ts b/apps/dapp/lib/__tests__/solana-exits.test.ts
new file mode 100644
index 00000000..11b363b2
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-exits.test.ts
@@ -0,0 +1,1051 @@
+import {
+ getAddressEncoder,
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getProgramDerivedAddress,
+ getTransactionDecoder,
+ type Address,
+} from '@solana/kit'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ KAMINO_HOLDER,
+ KAMINO_VAULT,
+ KAMINO_WITHDRAW_FROM_RESERVE_TEN_SHARES,
+ KAMINO_WITHDRAW_ONE_SHARE,
+ KAMINO_WITHDRAW_WHOLE,
+ LULO_HOLDER,
+ LULO_WITHDRAW_ONE,
+ LULO_WITHDRAW_WHOLE,
+ RESERVE_HOLDER,
+} from '../chains/solana/__fixtures__/captured-exits'
+import {
+ CAPTURE_ACCOUNT,
+ JUPITER_LEND_USDC_DEPOSIT,
+ KAMINO_USDC_DEPOSIT,
+ LULO_USDC_DEPOSIT,
+} from '../chains/solana/__fixtures__/captured-transactions'
+import {
+ assertExitAmount,
+ associatedTokenAccount,
+ decodeSolanaTransaction,
+} from '../chains/solana/assert-safe'
+import { createJupiterLendVenue } from '../chains/solana/jupiter-lend'
+import { createKaminoVenue } from '../chains/solana/kamino'
+import { createLuloVenue } from '../chains/solana/lulo'
+import {
+ JUPITER_LEND_ADMIN,
+ JUPITER_LEND_MARKETS,
+ JUPITER_LEND_PROGRAM,
+ JUPITER_LIQUIDITY,
+ JUPITER_LIQUIDITY_PROGRAM,
+ KAMINO_EXIT_POLICY,
+ KVAULT_GLOBAL_CONFIG,
+ KVAULT_PROGRAM,
+ KFARMS_PROGRAM,
+ WITHDRAW_DISCRIMINATOR,
+} from '../chains/solana/programs'
+import type { SolanaRpc } from '../chains/solana/stake-pool'
+import { ChainActionRefused, type SigningPayload } from '../chains/types'
+
+const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const USDT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
+const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
+const COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111'
+const WALLET = 'HFqp6ErWHY6Uzhj8rFyjYuDya2mXUpYEk8VW75K9PSiY'
+const ATTACKER = 'BPFLoaderUpgradeab1e11111111111111111111111'
+const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N'
+const U64_MAX = BigInt('18446744073709551615')
+
+const addressBytes = (address: string): Uint8Array =>
+ Uint8Array.from(getAddressEncoder().encode(address as Address))
+
+function le(value: bigint, bytes: number): Uint8Array {
+ const out = new Uint8Array(bytes)
+ let rest = value
+ for (let i = 0; i < bytes; i += 1) {
+ out[i] = Number(rest & BigInt(255))
+ rest >>= BigInt(8)
+ }
+ return out
+}
+
+function concat(...parts: Uint8Array[]): Uint8Array {
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
+ let at = 0
+ for (const part of parts) {
+ out.set(part, at)
+ at += part.length
+ }
+ return out
+}
+
+function tokenAccount(mint: string, owner: string, amount: bigint): Uint8Array {
+ return concat(addressBytes(mint), addressBytes(owner), le(amount, 8), new Uint8Array(165 - 72))
+}
+
+function chainAccount(owner: string, data: Uint8Array) {
+ return {
+ owner,
+ lamports: 1,
+ data: [Buffer.from(data).toString('base64'), 'base64'] as const,
+ }
+}
+
+/** A node that answers from a table of accounts. Anything else it is asked is a failure. */
+function nodeWith(accounts: Record>): SolanaRpc {
+ return async (method, params) => {
+ if (method === 'getLatestBlockhash') {
+ return { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 100 } }
+ }
+ if (method === 'getMultipleAccounts') {
+ return { value: (params[0] as string[]).map((address) => accounts[address] ?? null) }
+ }
+ throw new Error(`unexpected ${method}`)
+ }
+}
+
+const downNode: SolanaRpc = async () => {
+ throw new ChainActionRefused('Solana’s network could not be read just now.')
+}
+
+interface Message {
+ version: number | 'legacy'
+ header: { numSignerAccounts: number }
+ staticAccounts: readonly Address[]
+ lifetimeToken: string
+ instructions: readonly {
+ programAddressIndex: number
+ accountIndices?: readonly number[]
+ data?: Uint8Array
+ }[]
+}
+
+function open(base64: string): Message {
+ const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64'))
+ return getCompiledTransactionMessageDecoder().decode(messageBytes) as unknown as Message
+}
+
+function close(message: Message): string {
+ const bytes = getCompiledTransactionMessageEncoder().encode(
+ message as unknown as Parameters<
+ ReturnType['encode']
+ >[0]
+ )
+ const signers = message.header.numSignerAccounts
+ const unsigned = new Uint8Array(1 + signers * 64 + bytes.length)
+ unsigned[0] = signers
+ unsigned.set(bytes, 1 + signers * 64)
+ return Buffer.from(unsigned).toString('base64')
+}
+
+function swapAccount(base64: string, from: string, to: string): string {
+ const message = open(base64)
+ expect(message.staticAccounts).toContain(from)
+ return close({
+ ...message,
+ staticAccounts: message.staticAccounts.map((a) => (a === from ? (to as Address) : a)),
+ } as Message)
+}
+
+/** Adds a plain token transfer of the signer's tokens to a transaction. */
+function withTransfer(base64: string): string {
+ const message = open(base64)
+ const tokenIndex = message.staticAccounts.indexOf(TOKEN as Address)
+ return close({
+ ...message,
+ instructions: [
+ ...message.instructions,
+ {
+ programAddressIndex: tokenIndex,
+ accountIndices: [1, 1, 0],
+ data: Uint8Array.from([3, ...le(BigInt(1), 8)]),
+ },
+ ],
+ } as Message)
+}
+
+const solana = (transaction: string): SigningPayload => ({ family: 'solana', transaction })
+
+const usdcMarket = JUPITER_LEND_MARKETS[0]
+
+function stubFetch(
+ route: (url: string, init?: RequestInit) => { status?: number; body: unknown } | undefined
+): { calls: { url: string; body: unknown; headers: unknown }[] } {
+ const calls: { url: string; body: unknown; headers: unknown }[] = []
+ vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => {
+ const url = String(input)
+ calls.push({
+ url,
+ body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
+ headers: init?.headers,
+ })
+ const answer = route(url, init)
+ if (answer === undefined) throw new Error(`unexpected fetch ${url}`)
+ return new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })
+ })
+ return { calls }
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('jupiter lend pins', () => {
+ it('derives every pinned address from the seeds in Jupiter’s docs', async () => {
+ const encoder = getAddressEncoder()
+ const lend = JUPITER_LEND_PROGRAM as Address
+ const [admin] = await getProgramDerivedAddress({
+ programAddress: lend,
+ seeds: ['lending_admin'],
+ })
+ expect(admin).toBe(JUPITER_LEND_ADMIN)
+ const [liquidity] = await getProgramDerivedAddress({
+ programAddress: JUPITER_LIQUIDITY_PROGRAM as Address,
+ seeds: ['liquidity'],
+ })
+ expect(liquidity).toBe(JUPITER_LIQUIDITY)
+ expect(JUPITER_LEND_MARKETS.map((m) => m.mint)).toEqual([USDC, USDT])
+ for (const market of JUPITER_LEND_MARKETS) {
+ const mint = encoder.encode(market.mint as Address)
+ const [receipt] = await getProgramDerivedAddress({
+ programAddress: lend,
+ seeds: ['f_token_mint', mint],
+ })
+ expect(receipt).toBe(market.receipt)
+ const [pool] = await getProgramDerivedAddress({
+ programAddress: lend,
+ seeds: ['lending', mint, encoder.encode(receipt)],
+ })
+ expect(pool).toBe(market.pool)
+ }
+ })
+})
+
+describe('jupiter lend exit', () => {
+ const RESERVES = '94vK29npVbyRHXH63rRcTiSr26SFhrQTzbpNJuhQEDu'
+ const SUPPLY_POSITION = 'Hf9gtkM4dpVBahVSzEXSVCAPpKzBsBcns3s8As3z77oF'
+ const REWARDS = '5xSPBiD3TibamAnwHDhZABdB4z4F9dcj5PnbteroBTTd'
+ const PRICE = BigInt('1068357013115')
+ const SHARES = BigInt(48_398_806)
+
+ function poolBytes(market = usdcMarket as (typeof JUPITER_LEND_MARKETS)[number], mint?: string) {
+ return concat(
+ Uint8Array.from([135, 199, 82, 16, 249, 131, 182, 241]),
+ addressBytes(mint ?? market.mint),
+ addressBytes(market.receipt),
+ le(BigInt(2), 2),
+ Uint8Array.of(6),
+ addressBytes(REWARDS),
+ le(BigInt('1047874296785'), 8),
+ le(PRICE, 8),
+ le(BigInt(1793879576), 8),
+ addressBytes(RESERVES),
+ addressBytes(SUPPLY_POSITION),
+ Uint8Array.of(255)
+ )
+ }
+
+ function chain(shares: bigint = SHARES, pool: Uint8Array = poolBytes()) {
+ const accounts: Record> = {
+ [usdcMarket.pool]: chainAccount(JUPITER_LEND_PROGRAM, pool),
+ [JUPITER_LEND_MARKETS[1].pool]: chainAccount(
+ JUPITER_LEND_PROGRAM,
+ poolBytes(JUPITER_LEND_MARKETS[1])
+ ),
+ }
+ if (shares > BigInt(0)) {
+ accounts[associatedTokenAccount(WALLET, usdcMarket.receipt)] = chainAccount(
+ TOKEN,
+ tokenAccount(usdcMarket.receipt, WALLET, shares)
+ )
+ }
+ return nodeWith(accounts)
+ }
+
+ const withdraw = (amount: string, wholePosition?: boolean) =>
+ ({
+ kind: 'withdraw',
+ amount,
+ tokenIn: 'USDC',
+ ...(wholePosition === undefined ? {} : { wholePosition }),
+ }) as const
+
+ async function built(amount: string, wholePosition?: boolean) {
+ const venue = createJupiterLendVenue({ rpc: chain() })
+ const prepared = await venue.prepare(withdraw(amount, wholePosition), WALLET)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ return { venue, prepared, transaction: prepared.signing.transaction }
+ }
+
+ function exitInstruction(transaction: string) {
+ const decoded = decodeSolanaTransaction(transaction)
+ const instruction = decoded.instructions.find(
+ (i) => decoded.staticAccounts[i.programIndex] === JUPITER_LEND_PROGRAM
+ )
+ if (instruction === undefined) throw new Error('No withdrawal in this transaction.')
+ return {
+ data: Buffer.from(instruction.data),
+ accounts: instruction.accounts.map((index) => decoded.staticAccounts[index]),
+ }
+ }
+
+ it('turns shares into USDC at the pool’s rate, not one for one', async () => {
+ const venue = createJupiterLendVenue({ rpc: chain() })
+ expect(await venue.positions?.(WALLET)).toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '51.707203' },
+ ])
+ })
+
+ it('reads as nothing when the account holds no shares', async () => {
+ const venue = createJupiterLendVenue({ rpc: chain(BigInt(0)) })
+ expect(await venue.positions?.(WALLET)).toEqual([])
+ })
+
+ it('throws, rather than reading as empty, when the chain can’t be read', async () => {
+ const venue = createJupiterLendVenue({ rpc: downNode })
+ await expect(venue.positions?.(WALLET)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('throws when a pool isn’t what is pinned', async () => {
+ const venue = createJupiterLendVenue({ rpc: chain(SHARES, poolBytes(usdcMarket, USDT)) })
+ await expect(venue.positions?.(WALLET)).rejects.toThrow(/doesn’t look as expected/)
+ })
+
+ it('builds the amount asked, paying the wallet’s own token account', async () => {
+ const { venue, prepared, transaction } = await built('10.5')
+ const { data, accounts } = exitInstruction(transaction)
+ expect(data.subarray(0, 8).toString('hex')).toBe(WITHDRAW_DISCRIMINATOR)
+ expect(data.readBigUInt64LE(8)).toBe(BigInt(10_500_000))
+ expect(accounts[0]).toBe(WALLET)
+ expect(accounts[1]).toBe(associatedTokenAccount(WALLET, usdcMarket.receipt))
+ expect(accounts[2]).toBe(associatedTokenAccount(WALLET, USDC))
+ expect(accounts).toHaveLength(18)
+ expect(prepared).toMatchObject({
+ kind: 'withdraw',
+ pay: { amount: '10.5', symbol: 'USDC' },
+ receive: { amount: '10.5', symbol: 'USDC', approximate: false },
+ })
+ expect(() => venue.assertSafe(prepared.signing, WALLET)).not.toThrow()
+ })
+
+ it('needs no company server: nothing is fetched to build a withdrawal', async () => {
+ const { calls } = stubFetch(() => undefined)
+ await built('1')
+ expect(calls).toHaveLength(0)
+ })
+
+ it('empties a whole position with the program’s own everything value', async () => {
+ const { venue, prepared, transaction } = await built('51.707203', true)
+ expect(exitInstruction(transaction).data.readBigUInt64LE(8)).toBe(U64_MAX)
+ expect(prepared.receive).toMatchObject({ amount: '51.707203', approximate: true })
+ expect(() => venue.assertSafe(prepared.signing, WALLET)).not.toThrow()
+ })
+
+ it('refuses more than is held', async () => {
+ await expect(built('52')).rejects.toThrow(/about 51.707203 USDC.*less than 52/)
+ })
+
+ it('refuses when nothing is held', async () => {
+ const venue = createJupiterLendVenue({ rpc: chain(BigInt(0)) })
+ await expect(venue.prepare(withdraw('1'), WALLET)).rejects.toThrow(/no USDC on Jupiter Lend/)
+ })
+
+ it('refuses a token it doesn’t pay back', async () => {
+ const venue = createJupiterLendVenue({ rpc: chain() })
+ await expect(
+ venue.prepare({ kind: 'withdraw', amount: '1', tokenIn: 'SOL' }, WALLET)
+ ).rejects.toThrow(/USDC or USDT, not SOL/)
+ })
+
+ it('still passes the deposits it builds', () => {
+ const venue = createJupiterLendVenue({ rpc: chain() })
+ expect(() => venue.assertSafe(solana(JUPITER_LEND_USDC_DEPOSIT), CAPTURE_ACCOUNT)).not.toThrow()
+ })
+
+ describe('a tampered withdrawal', () => {
+ it('refuses another person’s token account as the recipient', async () => {
+ const { venue, transaction } = await built('1')
+ const tampered = swapAccount(transaction, associatedTokenAccount(WALLET, USDC), ATTACKER)
+ expect(() => venue.assertSafe(solana(tampered), WALLET)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a shares account that isn’t the person’s', async () => {
+ const { venue, transaction } = await built('1')
+ const tampered = swapAccount(
+ transaction,
+ associatedTokenAccount(WALLET, usdcMarket.receipt),
+ ATTACKER
+ )
+ expect(() => venue.assertSafe(solana(tampered), WALLET)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a transfer tucked in beside it', async () => {
+ const { venue, transaction } = await built('1')
+ expect(() => venue.assertSafe(solana(withTransfer(transaction)), WALLET)).toThrow(
+ /moves or approves tokens/
+ )
+ })
+
+ it('refuses being checked for a different signer', async () => {
+ const { venue, prepared } = await built('1')
+ expect(() => venue.assertSafe(prepared.signing, ATTACKER)).toThrow(/network fee/)
+ })
+
+ it('refuses a zero amount', async () => {
+ const { venue, transaction } = await built('1')
+ const message = open(transaction)
+ const tampered = close({
+ ...message,
+ instructions: message.instructions.map((i) =>
+ message.staticAccounts[i.programAddressIndex] === JUPITER_LEND_PROGRAM
+ ? {
+ ...i,
+ data: Uint8Array.from([
+ ...Buffer.from(WITHDRAW_DISCRIMINATOR, 'hex'),
+ ...le(BigInt(0), 8),
+ ]),
+ }
+ : i
+ ),
+ } as Message)
+ expect(() => venue.assertSafe(solana(tampered), WALLET)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses the redeem instruction, which only a deposit’s policy would ever see', async () => {
+ const { venue, transaction } = await built('1')
+ const message = open(transaction)
+ const redeem = close({
+ ...message,
+ instructions: message.instructions.map((i) =>
+ message.staticAccounts[i.programAddressIndex] === JUPITER_LEND_PROGRAM
+ ? {
+ ...i,
+ data: Uint8Array.from(Buffer.from(`b80c569546c461e1${'00'.repeat(8)}`, 'hex')),
+ }
+ : i
+ ),
+ } as Message)
+ expect(() => venue.assertSafe(solana(redeem), WALLET)).toThrow(ChainActionRefused)
+ })
+ })
+})
+
+describe('kamino exit', () => {
+ const FARM = '9FVjHqduhDPMVqvu3cXiEBjU6nvxvGdCCLRwd9WpVRZj'
+ const SHARES_MINT = '7D8C5pDFxug58L9zkwK7bCiDg4kD4AygzbcZUmf5usHS'
+ const OTHER_VAULT = 'QAYtEKciq4gc42K683rTaWu3JxqceA7JkEkZxwWUWBo'
+ const OTHER_SHARES_MINT = 'DgHN3q3dSYAchNX7V3D4aYiTWMx8RHTgHbfPiwiqBkE9'
+ const NO_FARM = new Uint8Array(32)
+
+ interface VaultSetup {
+ mint?: string
+ sharesMint?: string
+ farm?: Uint8Array
+ issued?: bigint
+ aum?: bigint
+ minWithdraw?: bigint
+ penaltyLamports?: bigint
+ penaltyBps?: bigint
+ name?: string
+ }
+
+ /** A `VaultState` in its real layout, with one share worth `aum / issued` tokens. */
+ function vaultBytes(setup: VaultSetup = {}): Uint8Array {
+ const data = new Uint8Array(62552)
+ data.set(addressBytes(setup.mint ?? USDC), 80)
+ data.set(addressBytes(setup.sharesMint ?? SHARES_MINT), 184)
+ data.set(le(BigInt(6), 8), 216)
+ data.set(le(setup.issued ?? BigInt(1000), 8), 232)
+ data.set(le((setup.aum ?? BigInt(1000)) << BigInt(60), 16), 280)
+ data.set(le(setup.minWithdraw ?? BigInt(0), 8), 58416)
+ data.set(new TextEncoder().encode(setup.name ?? 'Steakhouse USDC'), 58528)
+ data.set(setup.farm ?? addressBytes(FARM), 58600)
+ data.set(le(setup.penaltyLamports ?? BigInt(0), 8), 58680)
+ data.set(le(setup.penaltyBps ?? BigInt(0), 8), 58688)
+ return data
+ }
+
+ function globalBytes(lamports = BigInt(0), bps = BigInt(0)): Uint8Array {
+ const data = new Uint8Array(1032)
+ data.set(le(lamports, 8), 72)
+ data.set(le(bps, 8), 80)
+ return data
+ }
+
+ async function farmUserAddress(account: string, farm: string): Promise {
+ const encoder = getAddressEncoder()
+ const [address] = await getProgramDerivedAddress({
+ programAddress: KFARMS_PROGRAM as Address,
+ seeds: ['user', encoder.encode(farm as Address), encoder.encode(account as Address)],
+ })
+ return address
+ }
+
+ function farmUserBytes(account: string, shares: bigint): Uint8Array {
+ const data = new Uint8Array(920)
+ data.set(addressBytes(account), 48)
+ data.set(le(shares * BigInt(10) ** BigInt(18), 16), 408)
+ return data
+ }
+
+ async function chain(
+ account: string,
+ options: {
+ inWallet?: bigint
+ staked?: bigint
+ vault?: VaultSetup
+ global?: Uint8Array
+ extra?: Record>
+ } = {}
+ ) {
+ const { inWallet = BigInt(0), staked = BigInt(0) } = options
+ const sharesMint = options.vault?.sharesMint ?? SHARES_MINT
+ return nodeWith({
+ [KVAULT_GLOBAL_CONFIG]: chainAccount(KVAULT_PROGRAM, options.global ?? globalBytes()),
+ [KAMINO_VAULT]: chainAccount(KVAULT_PROGRAM, vaultBytes(options.vault)),
+ [associatedTokenAccount(account, sharesMint)]: chainAccount(
+ TOKEN,
+ tokenAccount(sharesMint, account, inWallet)
+ ),
+ [await farmUserAddress(account, FARM)]: chainAccount(
+ KFARMS_PROGRAM,
+ farmUserBytes(account, staked)
+ ),
+ ...options.extra,
+ })
+ }
+
+ function kaminoApi(
+ vaults: string[] = [KAMINO_VAULT],
+ transaction: string = KAMINO_WITHDRAW_ONE_SHARE
+ ) {
+ return stubFetch((url) => {
+ if (url.includes('/positions')) {
+ return { body: vaults.map((vaultAddress) => ({ vaultAddress, totalShares: '1' })) }
+ }
+ if (url.includes('/ktx/kvault/withdraw')) return { body: { transaction } }
+ return undefined
+ })
+ }
+
+ const withdraw = (amount: string, wholePosition?: boolean) =>
+ ({
+ kind: 'withdraw',
+ amount,
+ tokenIn: 'USDC',
+ ...(wholePosition === undefined ? {} : { wholePosition }),
+ }) as const
+
+ it('adds shares in the wallet and in the farm, and converts them at the vault’s rate', async () => {
+ kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, {
+ inWallet: BigInt(1_000_000),
+ staked: BigInt(3_000_000),
+ vault: { issued: BigInt(1000), aum: BigInt(1100) },
+ })
+ expect(await createKaminoVenue({ rpc }).positions?.(KAMINO_HOLDER)).toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '4.4' },
+ ])
+ })
+
+ it('shows what would actually arrive, after the charge read on chain', async () => {
+ kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, {
+ inWallet: BigInt(1_000_000),
+ global: globalBytes(BigInt(1000)),
+ })
+ expect(await createKaminoVenue({ rpc }).positions?.(KAMINO_HOLDER)).toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '0.999' },
+ ])
+ })
+
+ it('reads a vault with no farm from the wallet alone', async () => {
+ kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, {
+ inWallet: BigInt(2_000_000),
+ vault: { farm: NO_FARM },
+ })
+ expect(await createKaminoVenue({ rpc }).positions?.(KAMINO_HOLDER)).toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '2' },
+ ])
+ })
+
+ it('leaves out a vault of another token, or one not on Kamino’s program', async () => {
+ kaminoApi([KAMINO_VAULT, OTHER_VAULT])
+ const rpc = await chain(KAMINO_HOLDER, {
+ inWallet: BigInt(1_000_000),
+ vault: { mint: USDT },
+ extra: { [OTHER_VAULT]: chainAccount(ATTACKER, vaultBytes()) },
+ })
+ expect(await createKaminoVenue({ rpc }).positions?.(KAMINO_HOLDER)).toEqual([])
+ })
+
+ it('reads as nothing when Kamino lists no vaults', async () => {
+ kaminoApi([])
+ const venue = createKaminoVenue({ rpc: downNode })
+ expect(await venue.positions?.(KAMINO_HOLDER)).toEqual([])
+ })
+
+ it('throws when Kamino can’t say which vaults', async () => {
+ stubFetch(() => ({ status: 500, body: {} }))
+ const venue = createKaminoVenue({ rpc: downNode })
+ await expect(venue.positions?.(KAMINO_HOLDER)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('throws when the chain can’t be read', async () => {
+ kaminoApi()
+ const venue = createKaminoVenue({ rpc: downNode })
+ await expect(venue.positions?.(KAMINO_HOLDER)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('asks Kamino for the shares that pay the amount, and checks what comes back', async () => {
+ const { calls } = kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ const venue = createKaminoVenue({ rpc })
+ const prepared = await venue.prepare(withdraw('1'), KAMINO_HOLDER)
+ const asked = calls.find((c) => c.url.includes('/ktx/'))
+ expect(asked?.body).toEqual({ wallet: KAMINO_HOLDER, kvault: KAMINO_VAULT, amount: '1' })
+ expect(prepared).toMatchObject({ kind: 'withdraw', receive: { amount: '1', symbol: 'USDC' } })
+ expect(prepared.summary).toContain('Steakhouse USDC')
+ expect(prepared.notes.join(' ')).toMatch(/Kamino’s own server builds this/)
+ expect(() => venue.assertSafe(prepared.signing, KAMINO_HOLDER)).not.toThrow()
+ })
+
+ it('refuses a build for a different amount', async () => {
+ kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ await expect(createKaminoVenue({ rpc }).prepare(withdraw('2'), KAMINO_HOLDER)).rejects.toThrow(
+ /not the amount you asked for/
+ )
+ })
+
+ it('refuses a build that doesn’t pay the person', async () => {
+ const message = open(KAMINO_WITHDRAW_ONE_SHARE)
+ const recipient = associatedTokenAccount(KAMINO_HOLDER, USDC)
+ expect(message.staticAccounts).toContain(recipient)
+ kaminoApi(undefined, swapAccount(KAMINO_WITHDRAW_ONE_SHARE, recipient, ATTACKER))
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ await expect(createKaminoVenue({ rpc }).prepare(withdraw('1'), KAMINO_HOLDER)).rejects.toThrow(
+ /own token account/
+ )
+ })
+
+ it('sends the program’s everything value for the whole of a position', async () => {
+ const { calls } = kaminoApi(undefined, KAMINO_WITHDRAW_WHOLE)
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ const venue = createKaminoVenue({ rpc })
+ const prepared = await venue.prepare(withdraw('5', true), KAMINO_HOLDER)
+ expect(calls.find((c) => c.url.includes('/ktx/'))?.body).toMatchObject({
+ amount: '18446744073709.551615',
+ })
+ expect(prepared.receive).toMatchObject({ amount: '5', approximate: true })
+ expect(() => venue.assertSafe(prepared.signing, KAMINO_HOLDER)).not.toThrow()
+ })
+
+ it('counts a request for all of it as whole, once the shares reach what is held', async () => {
+ const { calls } = kaminoApi(undefined, KAMINO_WITHDRAW_WHOLE)
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ await createKaminoVenue({ rpc }).prepare(withdraw('5'), KAMINO_HOLDER)
+ expect(calls.find((c) => c.url.includes('/ktx/'))?.body).toMatchObject({
+ amount: '18446744073709.551615',
+ })
+ })
+
+ it('refuses a whole build that isn’t the everything value', async () => {
+ kaminoApi(undefined, KAMINO_WITHDRAW_ONE_SHARE)
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ await expect(
+ createKaminoVenue({ rpc }).prepare(withdraw('5', true), KAMINO_HOLDER)
+ ).rejects.toThrow(/not the amount you asked for/)
+ })
+
+ describe('with two vaults', () => {
+ async function twoVaults() {
+ kaminoApi([KAMINO_VAULT, OTHER_VAULT], KAMINO_WITHDRAW_WHOLE)
+ return chain(KAMINO_HOLDER, {
+ staked: BigInt(1_000_000),
+ extra: {
+ [OTHER_VAULT]: chainAccount(
+ KVAULT_PROGRAM,
+ vaultBytes({ sharesMint: OTHER_SHARES_MINT, farm: NO_FARM, name: 'RWA USDC' })
+ ),
+ [associatedTokenAccount(KAMINO_HOLDER, OTHER_SHARES_MINT)]: chainAccount(
+ TOKEN,
+ tokenAccount(OTHER_SHARES_MINT, KAMINO_HOLDER, BigInt(9_000_000))
+ ),
+ },
+ })
+ }
+
+ it('lists both, largest first, so "all" reads the one it would empty', async () => {
+ const rpc = await twoVaults()
+ expect(await createKaminoVenue({ rpc }).positions?.(KAMINO_HOLDER)).toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '9' },
+ { slot: 'supplied', symbol: 'USDC', amount: '1' },
+ ])
+ })
+
+ it('empties the largest and says the other is still there', async () => {
+ const rpc = await twoVaults()
+ const prepared = await createKaminoVenue({ rpc }).prepare(withdraw('9', true), KAMINO_HOLDER)
+ expect(prepared.summary).toContain('RWA USDC')
+ expect(prepared.notes.join(' ')).toMatch(
+ /also have about 1 USDC in Kamino's "Steakhouse USDC"/
+ )
+ })
+
+ it('refuses an amount no single vault holds, and names what can leave', async () => {
+ const rpc = await twoVaults()
+ await expect(
+ createKaminoVenue({ rpc }).prepare(withdraw('10'), KAMINO_HOLDER)
+ ).rejects.toThrow(/largest Kamino USDC vault holds about 9 USDC/)
+ })
+ })
+
+ it('refuses when nothing is held', async () => {
+ kaminoApi([])
+ await expect(
+ createKaminoVenue({ rpc: downNode }).prepare(withdraw('1'), KAMINO_HOLDER)
+ ).rejects.toThrow(/no USDC on Kamino/)
+ })
+
+ describe('when the chain would reject what Kamino built', () => {
+ function simulating(rpc: SolanaRpc, errors: unknown[]): SolanaRpc {
+ return async (method, params) =>
+ method === 'simulateTransaction'
+ ? { value: { err: errors.shift() ?? null } }
+ : rpc(method, params)
+ }
+
+ it('builds once more, since the vault’s cash moves within minutes', async () => {
+ const { calls } = kaminoApi()
+ const rpc = simulating(await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) }), [
+ { InstructionError: [4, { Custom: 7004 }] },
+ ])
+ const prepared = await createKaminoVenue({ rpc }).prepare(withdraw('1'), KAMINO_HOLDER)
+ expect(prepared.kind).toBe('withdraw')
+ expect(calls.filter((c) => c.url.includes('/ktx/'))).toHaveLength(2)
+ })
+
+ it('refuses in plain words if that is rejected too, rather than cost a fee', async () => {
+ const rejected = { InstructionError: [4, { Custom: 7004 }] }
+ kaminoApi()
+ const rpc = simulating(await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) }), [
+ rejected,
+ rejected,
+ ])
+ await expect(
+ createKaminoVenue({ rpc }).prepare(withdraw('1'), KAMINO_HOLDER)
+ ).rejects.toThrow(/can’t pay this out at the moment/)
+ })
+
+ it('lets a good exit through when the simulation itself can’t run', async () => {
+ kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, { staked: BigInt(5_000_000) })
+ const prepared = await createKaminoVenue({ rpc }).prepare(withdraw('1'), KAMINO_HOLDER)
+ expect(prepared.kind).toBe('withdraw')
+ })
+ })
+
+ it('refuses at or under the vault’s least withdrawal, as the program would', async () => {
+ kaminoApi()
+ const rpc = await chain(KAMINO_HOLDER, {
+ staked: BigInt(5_000_000),
+ vault: { minWithdraw: BigInt(1_000_000) },
+ })
+ await expect(createKaminoVenue({ rpc }).prepare(withdraw('1'), KAMINO_HOLDER)).rejects.toThrow(
+ /only pays out more than 1 USDC/
+ )
+ })
+
+ it('refuses a token it doesn’t pay back', async () => {
+ await expect(
+ createKaminoVenue({ rpc: downNode }).prepare(
+ { kind: 'withdraw', amount: '1', tokenIn: 'SOL' },
+ KAMINO_HOLDER
+ )
+ ).rejects.toThrow(/USDC only, not SOL/)
+ })
+
+ describe('the proof over what Kamino’s API built', () => {
+ const safe = createKaminoVenue({ rpc: downNode }).assertSafe
+
+ it.each([
+ ['from what the vault has free, unstaking first', KAMINO_WITHDRAW_ONE_SHARE, KAMINO_HOLDER],
+ ['of a whole position, closing the emptied account', KAMINO_WITHDRAW_WHOLE, KAMINO_HOLDER],
+ ['drawing on a lending market', KAMINO_WITHDRAW_FROM_RESERVE_TEN_SHARES, RESERVE_HOLDER],
+ ])('passes a withdrawal %s', (_, transaction, holder) => {
+ expect(() => safe(solana(transaction), holder)).not.toThrow()
+ })
+
+ it('still passes a deposit, and holds it to the deposit’s own rules', () => {
+ expect(() => safe(solana(KAMINO_USDC_DEPOSIT), CAPTURE_ACCOUNT)).not.toThrow()
+ })
+
+ it('refuses another person’s token account as the recipient', () => {
+ const tampered = swapAccount(
+ KAMINO_WITHDRAW_ONE_SHARE,
+ associatedTokenAccount(KAMINO_HOLDER, USDC),
+ ATTACKER
+ )
+ expect(() => safe(solana(tampered), KAMINO_HOLDER)).toThrow(/own token account/)
+ })
+
+ it('refuses unstaked shares paid anywhere but the account the withdrawal burns', () => {
+ const message = open(KAMINO_WITHDRAW_ONE_SHARE)
+ const recipient = message.staticAccounts.indexOf(
+ associatedTokenAccount(KAMINO_HOLDER, USDC) as Address
+ )
+ const tampered = close({
+ ...message,
+ instructions: message.instructions.map((i) =>
+ (i.data ?? new Uint8Array()).length === 8 &&
+ message.staticAccounts[i.programAddressIndex] === KFARMS_PROGRAM
+ ? {
+ ...i,
+ accountIndices: (i.accountIndices ?? []).map((index, at) =>
+ at === 3 ? recipient : index
+ ),
+ }
+ : i
+ ),
+ } as Message)
+ expect(() => safe(solana(tampered), KAMINO_HOLDER)).toThrow(/can’t confirm is yours/)
+ })
+
+ it('refuses closing any token account but the emptied shares account', () => {
+ const message = open(KAMINO_WITHDRAW_WHOLE)
+ const recipient = message.staticAccounts.indexOf(
+ associatedTokenAccount(KAMINO_HOLDER, USDC) as Address
+ )
+ const tampered = close({
+ ...message,
+ instructions: message.instructions.map((i) =>
+ message.staticAccounts[i.programAddressIndex] === TOKEN
+ ? {
+ ...i,
+ accountIndices: (i.accountIndices ?? []).map((index, at) =>
+ at === 0 ? recipient : index
+ ),
+ }
+ : i
+ ),
+ } as Message)
+ expect(() => safe(solana(tampered), KAMINO_HOLDER)).toThrow(/closes a token account/)
+ })
+
+ it('refuses a close that pays someone else the rent', () => {
+ const message = open(KAMINO_WITHDRAW_WHOLE)
+ const recipient = message.staticAccounts.indexOf(
+ associatedTokenAccount(KAMINO_HOLDER, USDC) as Address
+ )
+ const tampered = close({
+ ...message,
+ instructions: message.instructions.map((i) =>
+ message.staticAccounts[i.programAddressIndex] === TOKEN
+ ? {
+ ...i,
+ accountIndices: (i.accountIndices ?? []).map((index, at) =>
+ at === 1 ? recipient : index
+ ),
+ }
+ : i
+ ),
+ } as Message)
+ expect(() => safe(solana(tampered), KAMINO_HOLDER)).toThrow(/does not pay you back/)
+ })
+
+ it('refuses a transfer tucked in beside it', () => {
+ expect(() => safe(solana(withTransfer(KAMINO_WITHDRAW_WHOLE)), KAMINO_HOLDER)).toThrow(
+ /moves tokens outside/
+ )
+ })
+
+ it('refuses a program that isn’t part of the withdrawal', () => {
+ const message = open(KAMINO_WITHDRAW_ONE_SHARE)
+ const tampered = close({
+ ...message,
+ staticAccounts: message.staticAccounts.map((a) =>
+ a === KVAULT_PROGRAM ? ('CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK' as Address) : a
+ ),
+ } as Message)
+ expect(() => safe(solana(tampered), KAMINO_HOLDER)).toThrow(ChainActionRefused)
+ })
+
+ it('binds the amount: the sum paid out, or the everything value and nothing less', () => {
+ const { rules } = KAMINO_EXIT_POLICY
+ expect(() =>
+ assertExitAmount(KAMINO_WITHDRAW_ONE_SHARE, rules, BigInt(1_000_000))
+ ).not.toThrow()
+ expect(() => assertExitAmount(KAMINO_WITHDRAW_ONE_SHARE, rules, BigInt(999_999))).toThrow(
+ ChainActionRefused
+ )
+ expect(() => assertExitAmount(KAMINO_WITHDRAW_ONE_SHARE, rules, U64_MAX)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => assertExitAmount(KAMINO_WITHDRAW_WHOLE, rules, U64_MAX)).not.toThrow()
+ expect(() => assertExitAmount(KAMINO_WITHDRAW_WHOLE, rules, BigInt(1_000_000))).toThrow(
+ ChainActionRefused
+ )
+ })
+ })
+})
+
+describe('lulo exit', () => {
+ const venue = () => createLuloVenue({ apiKey: 'lulo-key' })
+
+ function luloApi(
+ options: { maxWithdrawable?: number; transaction?: string; status?: number } = {}
+ ) {
+ return stubFetch((url) => {
+ if (url.includes('account.getAccount')) {
+ return {
+ status: options.status ?? 200,
+ body: {
+ maxWithdrawable: {
+ protected: { [USDC]: options.maxWithdrawable ?? 3579.6813034693546 },
+ regular: { [USDC]: 3.7e-13 },
+ },
+ },
+ }
+ }
+ if (url.includes('generate.transactions.withdrawProtected')) {
+ return { body: { transaction: options.transaction ?? LULO_WITHDRAW_ONE } }
+ }
+ return undefined
+ })
+ }
+
+ const withdraw = (amount: string, wholePosition?: boolean) =>
+ ({
+ kind: 'withdraw',
+ amount,
+ tokenIn: 'USDC',
+ ...(wholePosition === undefined ? {} : { wholePosition }),
+ }) as const
+
+ it('reads what can leave, in six places, with the key', async () => {
+ const { calls } = luloApi()
+ expect(await venue().positions?.(LULO_HOLDER)).toEqual([
+ { slot: 'supplied', symbol: 'USDC', amount: '3579.681303' },
+ ])
+ expect(calls[0]?.headers).toMatchObject({ 'x-api-key': 'lulo-key' })
+ })
+
+ it('counts a floating-point crumb as nothing', async () => {
+ luloApi({ maxWithdrawable: 3.7e-13 })
+ expect(await venue().positions?.(LULO_HOLDER)).toEqual([])
+ })
+
+ it('throws, rather than reading as empty, when Lulo won’t answer', async () => {
+ luloApi({ status: 401 })
+ await expect(venue().positions?.(LULO_HOLDER)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('builds the amount asked and proves it pays the person', async () => {
+ const { calls } = luloApi()
+ const prepared = await venue().prepare(withdraw('1'), LULO_HOLDER)
+ expect(calls.find((c) => c.url.includes('withdrawProtected'))?.body).toEqual({
+ owner: LULO_HOLDER,
+ mintAddress: USDC,
+ amount: 1,
+ })
+ expect(prepared).toMatchObject({
+ kind: 'withdraw',
+ receive: { amount: '1', symbol: 'USDC', approximate: false },
+ })
+ expect(() => venue().assertSafe(prepared.signing, LULO_HOLDER)).not.toThrow()
+ })
+
+ it('empties a whole position by asking for more than it could hold', async () => {
+ const { calls } = luloApi({ transaction: LULO_WITHDRAW_WHOLE })
+ const prepared = await venue().prepare(withdraw('3579.681303', true), LULO_HOLDER)
+ expect(calls.find((c) => c.url.includes('withdrawProtected'))?.body).toMatchObject({
+ amount: 99_999_999,
+ })
+ expect(prepared.receive).toMatchObject({ amount: '3579.681303', approximate: true })
+ expect(prepared.notes.join(' ')).toMatch(/asks for more than you hold/)
+ expect(() => venue().assertSafe(prepared.signing, LULO_HOLDER)).not.toThrow()
+ })
+
+ it('refuses more than can leave, since the program would quietly pay less', async () => {
+ luloApi({ maxWithdrawable: 0.5 })
+ await expect(venue().prepare(withdraw('1'), LULO_HOLDER)).rejects.toThrow(
+ /0.5 USDC.*less than 1/
+ )
+ })
+
+ it('refuses a build for a different amount', async () => {
+ luloApi({ transaction: LULO_WITHDRAW_ONE })
+ await expect(venue().prepare(withdraw('2'), LULO_HOLDER)).rejects.toThrow(
+ /not the amount you asked for/
+ )
+ })
+
+ it('refuses a build that pays someone else', async () => {
+ const tampered = swapAccount(
+ LULO_WITHDRAW_ONE,
+ associatedTokenAccount(LULO_HOLDER, USDC),
+ ATTACKER
+ )
+ luloApi({ transaction: tampered })
+ await expect(venue().prepare(withdraw('1'), LULO_HOLDER)).rejects.toThrow(/own token account/)
+ })
+
+ it('refuses when Lulo’s own simulation failed', async () => {
+ stubFetch((url) =>
+ url.includes('account.getAccount')
+ ? { body: { maxWithdrawable: { protected: { [USDC]: 5 } } } }
+ : { body: { transaction: LULO_WITHDRAW_ONE, simulateError: 'InsufficientFunds' } }
+ )
+ await expect(venue().prepare(withdraw('1'), LULO_HOLDER)).rejects.toThrow(/would fail/)
+ })
+
+ it('refuses when no transaction comes back', async () => {
+ stubFetch((url) =>
+ url.includes('account.getAccount')
+ ? { body: { maxWithdrawable: { protected: { [USDC]: 5 } } } }
+ : { status: 401, body: {} }
+ )
+ await expect(venue().prepare(withdraw('1'), LULO_HOLDER)).rejects.toThrow(
+ /could not build this withdrawal/
+ )
+ })
+
+ it('refuses a token it doesn’t pay back', async () => {
+ await expect(
+ venue().prepare({ kind: 'withdraw', amount: '1', tokenIn: 'SOL' }, LULO_HOLDER)
+ ).rejects.toThrow(/USDC only, not SOL/)
+ })
+
+ it('refuses another person’s token account at submit, and a transfer beside it', () => {
+ const safe = venue().assertSafe
+ const tampered = swapAccount(
+ LULO_WITHDRAW_ONE,
+ associatedTokenAccount(LULO_HOLDER, USDC),
+ ATTACKER
+ )
+ expect(() => safe(solana(tampered), LULO_HOLDER)).toThrow(/own token account/)
+ const message = open(LULO_WITHDRAW_ONE)
+ const budget = message.staticAccounts.indexOf(COMPUTE_BUDGET as Address)
+ const withExtra = close({
+ ...message,
+ instructions: [
+ ...message.instructions,
+ {
+ programAddressIndex: budget,
+ accountIndices: [],
+ data: Uint8Array.from([3, ...le(BigInt('999999999999'), 8)]),
+ },
+ ],
+ } as Message)
+ expect(() => safe(solana(withExtra), LULO_HOLDER)).toThrow(/priority fee/)
+ })
+
+ it('still passes a deposit, and refuses Kamino’s withdrawal', () => {
+ const safe = venue().assertSafe
+ expect(() => safe(solana(LULO_USDC_DEPOSIT), CAPTURE_ACCOUNT)).not.toThrow()
+ expect(() => safe(solana(KAMINO_WITHDRAW_ONE_SHARE), KAMINO_HOLDER)).toThrow(ChainActionRefused)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/solana-stake-fixtures.ts b/apps/dapp/lib/__tests__/solana-stake-fixtures.ts
new file mode 100644
index 00000000..451a520e
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-stake-fixtures.ts
@@ -0,0 +1,324 @@
+import {
+ appendTransactionMessageInstructions,
+ compileTransaction,
+ createTransactionMessage,
+ getAddressEncoder,
+ getBase64EncodedWireTransaction,
+ pipe,
+ setTransactionMessageFeePayer,
+ setTransactionMessageLifetimeUsingBlockhash,
+ type Address,
+ type Blockhash,
+ type Instruction,
+} from '@solana/kit'
+
+import {
+ ASSOCIATED_TOKEN_PROGRAM,
+ decodeSolanaTransaction,
+ SYSTEM_PROGRAM,
+ TOKEN_PROGRAM,
+} from '../chains/solana/assert-safe'
+import {
+ accountRoles,
+ computeBudget,
+ STAKE_PROGRAM,
+ type SolanaRpc,
+} from '../chains/solana/stake-pool'
+import type { PreparedAction, SigningPayload } from '../chains/types'
+
+/** Test inputs only. Nothing here signs or sends, and the transport never touches a network. */
+
+export const ME = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+export const SOMEONE_ELSE = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'
+export const UNKNOWN_PROGRAM = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'
+export const BLOCKHASH = 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N'
+
+const { readonly, signer, writable } = accountRoles
+
+export function compileUnsigned(feePayer: string, instructions: readonly Instruction[]): string {
+ const message = pipe(
+ createTransactionMessage({ version: 'legacy' }),
+ (m) => setTransactionMessageFeePayer(feePayer as Address, m),
+ (m) =>
+ setTransactionMessageLifetimeUsingBlockhash(
+ { blockhash: BLOCKHASH as Blockhash, lastValidBlockHeight: BigInt(1) },
+ m
+ ),
+ (m) => appendTransactionMessageInstructions(instructions, m)
+ )
+ return getBase64EncodedWireTransaction(compileTransaction(message))
+}
+
+export function payload(feePayer: string, instructions: readonly Instruction[]): SigningPayload {
+ return { family: 'solana', transaction: compileUnsigned(feePayer, instructions) }
+}
+
+export const budget = computeBudget(200_000)
+
+export function transferOf(from: string, to: string, lamports: bigint): Instruction {
+ const bytes = new Uint8Array(12)
+ bytes[0] = 2
+ for (let i = 0; i < 8; i += 1) bytes[4 + i] = Number((lamports >> BigInt(8 * i)) & BigInt(255))
+ return {
+ programAddress: SYSTEM_PROGRAM as Address,
+ accounts: [signer(from), writable(to)],
+ data: bytes,
+ }
+}
+
+/** The Token Program's `approve` with the largest amount there is. */
+export function unlimitedApproval(
+ tokenAccount: string,
+ delegate: string,
+ owner: string
+): Instruction {
+ return {
+ programAddress: TOKEN_PROGRAM as Address,
+ accounts: [writable(tokenAccount), readonly(delegate), signer(owner)],
+ data: Uint8Array.from([4, 255, 255, 255, 255, 255, 255, 255, 255]),
+ }
+}
+
+/** The same instruction with extra bytes after what the program reads. */
+export function withExtraData(instruction: Instruction): Instruction {
+ return { ...instruction, data: Uint8Array.from([...(instruction.data ?? []), 0]) }
+}
+
+/** The same instruction with a different discriminator or tag. */
+export function withOtherFunction(instruction: Instruction): Instruction {
+ const data = Uint8Array.from(instruction.data ?? [])
+ data[0] = (data[0] ?? 0) ^ 255
+ return { ...instruction, data }
+}
+
+export function withAccount(
+ instruction: Instruction,
+ position: number,
+ address: string
+): Instruction {
+ const accounts = [...(instruction.accounts ?? [])]
+ accounts[position] = writable(address)
+ return { ...instruction, accounts }
+}
+
+export function instructionsOf(prepared: PreparedAction): Instruction[] {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ return decoded.instructions.map((instruction) => ({
+ programAddress: decoded.staticAccounts[instruction.programIndex] as Address,
+ accounts: instruction.accounts.map((index) => ({
+ address: decoded.staticAccounts[index] as Address,
+ role: accountRoles.readonly('11111111111111111111111111111111').role,
+ })),
+ data: instruction.data,
+ }))
+}
+
+const NEVER_WRITABLE = new Set([
+ SYSTEM_PROGRAM,
+ TOKEN_PROGRAM,
+ ASSOCIATED_TOKEN_PROGRAM,
+ STAKE_PROGRAM,
+ 'SysvarC1ock11111111111111111111111111111111',
+ 'SysvarStakeHistory1111111111111111111111111',
+])
+
+/** The instructions as built, with roles restored so that they compile back to one signer. */
+export function rebuilt(prepared: PreparedAction, account: string): Instruction[] {
+ const instructions = instructionsOf(prepared)
+ const programs = new Set(instructions.map((i) => i.programAddress))
+ return instructions.map((instruction) => ({
+ ...instruction,
+ accounts: (instruction.accounts ?? []).map((meta) => {
+ if (meta.address === account) return signer(account)
+ return programs.has(meta.address) || NEVER_WRITABLE.has(meta.address)
+ ? readonly(meta.address)
+ : writable(meta.address)
+ }),
+ }))
+}
+
+export function addressBytes(address: string): number[] {
+ return [...getAddressEncoder().encode(address as Address)]
+}
+
+export function u64Bytes(value: bigint): number[] {
+ return Array.from({ length: 8 }, (_, i) => Number((value >> BigInt(8 * i)) & BigInt(255)))
+}
+
+export function u32Bytes(value: number): number[] {
+ return Array.from({ length: 4 }, (_, i) => (value >>> (8 * i)) & 255)
+}
+
+export interface FakeAccount {
+ owner: string
+ lamports: bigint
+ data: Uint8Array
+}
+
+/** A transport that answers from a table, and refuses anything that could send. */
+export function fakeRpc(options: {
+ accounts: Record
+ epoch: number
+ rent?: number
+ slotIndex?: number
+ simulationError?: unknown
+ /** Methods that fail, as an unreachable node would. */
+ unreadable?: readonly string[]
+}): SolanaRpc & { calls: string[] } {
+ const calls: string[] = []
+ const reply = (address: string) => {
+ const account = options.accounts[address]
+ return account === undefined
+ ? null
+ : {
+ owner: account.owner,
+ lamports: Number(account.lamports),
+ data: [Buffer.from(account.data).toString('base64'), 'base64'],
+ }
+ }
+ const rpc = async (method: string, params: unknown[]): Promise => {
+ calls.push(method)
+ if (options.unreadable?.includes(method) === true) throw new Error(`${method} is down.`)
+ switch (method) {
+ case 'getAccountInfo':
+ return { value: reply(params[0] as string) }
+ case 'getMultipleAccounts':
+ // publicnode, the default node, allows no more than this.
+ if ((params[0] as string[]).length > 10) throw new Error('Too many accounts in one read.')
+ return { value: (params[0] as string[]).map(reply) }
+ case 'simulateTransaction':
+ return { value: { err: options.simulationError ?? null } }
+ case 'getEpochInfo':
+ return { epoch: options.epoch, slotIndex: options.slotIndex ?? 0, slotsInEpoch: 432_000 }
+ case 'getLatestBlockhash':
+ return { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 1 } }
+ case 'getMinimumBalanceForRentExemption':
+ return options.rent ?? 1_666_240
+ case 'getStakeMinimumDelegation':
+ return { value: 1_000_000_000 }
+ default:
+ throw new Error(`This test must not call ${method}.`)
+ }
+ }
+ return Object.assign(rpc, { calls })
+}
+
+export interface StakeAccountFixture {
+ tag?: number
+ staker?: string
+ withdrawer: string
+ lockupTimestamp?: bigint
+ lockupEpoch?: bigint
+ custodian?: string
+ voter?: string
+ delegated?: bigint
+ activationEpoch?: bigint
+ deactivationEpoch?: bigint
+}
+
+export const NEVER_DEACTIVATED = BigInt('18446744073709551615')
+
+/** A stake account in the stake program's own layout, field by field: `StakeStateV2`. */
+export function encodeStakeAccount(fixture: StakeAccountFixture): Uint8Array {
+ const zero = addressBytes(SYSTEM_PROGRAM)
+ return Uint8Array.from([
+ ...u32Bytes(fixture.tag ?? 2),
+ ...u64Bytes(BigInt(2_282_880)),
+ ...addressBytes(fixture.staker ?? fixture.withdrawer),
+ ...addressBytes(fixture.withdrawer),
+ ...u64Bytes(fixture.lockupTimestamp ?? BigInt(0)),
+ ...u64Bytes(fixture.lockupEpoch ?? BigInt(0)),
+ ...(fixture.custodian === undefined ? zero : addressBytes(fixture.custodian)),
+ ...(fixture.voter === undefined ? zero : addressBytes(fixture.voter)),
+ ...u64Bytes(fixture.delegated ?? BigInt(0)),
+ ...u64Bytes(fixture.activationEpoch ?? BigInt(695)),
+ ...u64Bytes(fixture.deactivationEpoch ?? NEVER_DEACTIVATED),
+ ...u64Bytes(BigInt(0)),
+ ...u64Bytes(BigInt(0)),
+ ...u32Bytes(0),
+ ])
+}
+
+export interface HistoryEntry {
+ epoch: bigint
+ effective: bigint
+ activating: bigint
+ deactivating: bigint
+}
+
+/** The stake history sysvar's layout, newest first, as read from mainnet on 5 November 2026. */
+export function encodeStakeHistory(entries: readonly HistoryEntry[]): Uint8Array {
+ return Uint8Array.from([
+ ...u64Bytes(BigInt(entries.length)),
+ ...entries.flatMap((entry) => [
+ ...u64Bytes(entry.epoch),
+ ...u64Bytes(entry.effective),
+ ...u64Bytes(entry.activating),
+ ...u64Bytes(entry.deactivating),
+ ]),
+ ])
+}
+
+export interface PoolFixture {
+ poolMint: string
+ reserveStake: string
+ managerFeeAccount: string
+ validatorList: string
+ totalLamports: bigint
+ poolTokenSupply: bigint
+ lastUpdateEpoch: bigint
+ solWithdrawalFee?: [bigint, bigint]
+ stakeWithdrawalFee?: [bigint, bigint]
+ solDepositAuthority?: string
+ solWithdrawAuthority?: string
+}
+
+const NO_FEE = [BigInt(0), BigInt(0)] as const
+
+function fee([numerator, denominator]: readonly [bigint, bigint]): number[] {
+ return [...u64Bytes(denominator), ...u64Bytes(numerator)]
+}
+
+function option(address: string | undefined): number[] {
+ return address === undefined ? [0] : [1, ...addressBytes(address)]
+}
+
+/** A stake pool in the program's own Borsh layout, field by field. */
+export function encodeStakePool(fixture: PoolFixture): Uint8Array {
+ const zero = addressBytes(SYSTEM_PROGRAM)
+ return Uint8Array.from([
+ 1,
+ ...zero,
+ ...zero,
+ ...zero,
+ 255,
+ ...addressBytes(fixture.validatorList),
+ ...addressBytes(fixture.reserveStake),
+ ...addressBytes(fixture.poolMint),
+ ...addressBytes(fixture.managerFeeAccount),
+ ...addressBytes(TOKEN_PROGRAM),
+ ...u64Bytes(fixture.totalLamports),
+ ...u64Bytes(fixture.poolTokenSupply),
+ ...u64Bytes(fixture.lastUpdateEpoch),
+ ...u64Bytes(BigInt(0)),
+ ...u64Bytes(BigInt(0)),
+ ...zero,
+ ...fee([BigInt(4), BigInt(100)]),
+ 0,
+ 0,
+ 0,
+ ...fee(NO_FEE),
+ ...fee(fixture.stakeWithdrawalFee ?? NO_FEE),
+ 0,
+ 0,
+ ...option(fixture.solDepositAuthority),
+ ...fee(NO_FEE),
+ 0,
+ ...option(fixture.solWithdrawAuthority),
+ ...fee(fixture.solWithdrawalFee ?? NO_FEE),
+ 0,
+ ...u64Bytes(BigInt(0)),
+ ...u64Bytes(BigInt(0)),
+ ])
+}
diff --git a/apps/dapp/lib/__tests__/solana-staking.live.test.ts b/apps/dapp/lib/__tests__/solana-staking.live.test.ts
new file mode 100644
index 00000000..4e100096
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-staking.live.test.ts
@@ -0,0 +1,279 @@
+import { z } from 'zod'
+import { describe, expect, it } from 'vitest'
+
+import { bytesFromBase64 } from '../chains/base64'
+import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import { createJitoVenue, JITO_STAKE_POOL, JITOSOL_MINT } from '../chains/solana/jito'
+import {
+ createMarinadeVenue,
+ decodeMarinadeState,
+ MARINADE_PROGRAM,
+ MARINADE_STATE,
+ MSOL_MINT,
+} from '../chains/solana/marinade'
+import {
+ createSolayerVenue,
+ SOLAYER_LP_MINT,
+ SOLAYER_RESTAKING_POOL,
+ SOLAYER_RESTAKING_PROGRAM,
+ SOLAYER_STAKE_POOL,
+ SSOL_MINT,
+} from '../chains/solana/solayer'
+import {
+ decodeStakePool,
+ decodeValidatorList,
+ readAccount,
+ readAddress,
+ readOnlyRpc,
+ readU64,
+ STAKE_POOL_PROGRAM,
+} from '../chains/solana/stake-pool'
+import type { ChainVenue, PreparedAction } from '../chains/types'
+
+/**
+ * Read-only checks that the pinned ids still hold on chain, and that what the venues build runs,
+ * by the network's simulation. `simulateTransaction` here has signature checks off, and nothing is
+ * ever signed or sent. The wallets are public: a funded one, and a large holder of each token.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 180_000
+const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
+const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+const env = {
+ ...process.env,
+ SOLANA_RPC_URL: process.env['SOLANA_RPC_URL'] ?? 'https://api.mainnet-beta.solana.com',
+}
+const baseRpc = readOnlyRpc(env)
+
+const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms))
+
+/** The public node allows only so much, and a refused read proves nothing about what's pinned. */
+const rpc = async (method: string, params: unknown[]): Promise => {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ await wait(400)
+ return await baseRpc(method, params)
+ } catch (e) {
+ if (attempt >= 4) throw e
+ await wait(5_000)
+ }
+ }
+}
+
+const Simulated = z.object({
+ value: z.object({
+ err: z.unknown().nullable(),
+ logs: z.array(z.string()).nullable(),
+ unitsConsumed: z.number().optional(),
+ accounts: z
+ .array(z.object({ lamports: z.number(), data: z.tuple([z.string(), z.string()]) }).nullable())
+ .nullable()
+ .optional(),
+ }),
+})
+
+async function simulate(
+ prepared: PreparedAction,
+ addresses: string[] = []
+): Promise['value']> {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ expect(decodeSolanaTransaction(prepared.signing.transaction).feePayerSignature).toBeNull()
+ const reply = await rpc('simulateTransaction', [
+ prepared.signing.transaction,
+ {
+ encoding: 'base64',
+ sigVerify: false,
+ replaceRecentBlockhash: true,
+ commitment: 'confirmed',
+ ...(addresses.length === 0 ? {} : { accounts: { addresses, encoding: 'base64' } }),
+ },
+ ])
+ return Simulated.parse(reply).value
+}
+
+function expectRan(result: z.infer['value']): void {
+ if (result.err !== null) console.error(JSON.stringify(result.logs?.slice(-14), null, 1))
+ expect(result.err).toBeNull()
+}
+
+/** A token account's amount as it stands now, zero if it doesn't exist yet. */
+async function tokenAmount(address: string): Promise {
+ const reply = TokenBalance.safeParse(
+ await rpc('getTokenAccountBalance', [address]).catch(() => undefined)
+ )
+ return reply.success ? BigInt(reply.data.value.amount) : BigInt(0)
+}
+
+const baseUnits = (text: string | undefined): bigint =>
+ BigInt(Math.round(Number(text) * 1_000_000_000))
+
+const Signatures = z.array(z.object({ signature: z.string(), err: z.unknown().nullable() }))
+const TransactionBalances = z.object({
+ meta: z.object({
+ postTokenBalances: z.array(
+ z.object({
+ mint: z.string(),
+ owner: z.string().optional(),
+ uiTokenAmount: z.object({ amount: z.string() }),
+ })
+ ),
+ }),
+})
+const Wallet = z.object({ value: z.object({ owner: z.string(), lamports: z.number() }) })
+const TokenBalance = z.object({ value: z.object({ amount: z.string() }) })
+
+/**
+ * A plain wallet holding at least this much of a token in its own associated account, with SOL for
+ * the fee, found among whoever last traded it. The public node refuses the largest-holders call.
+ */
+async function holderOf(mint: string, atLeast: bigint): Promise {
+ const signatures = Signatures.parse(await rpc('getSignaturesForAddress', [mint, { limit: 120 }]))
+ for (const { signature, err } of signatures) {
+ if (err !== null) continue
+ const transaction = TransactionBalances.safeParse(
+ await rpc('getTransaction', [
+ signature,
+ { encoding: 'json', maxSupportedTransactionVersion: 1 },
+ ])
+ )
+ if (!transaction.success) continue
+ for (const { mint: held, owner, uiTokenAmount } of transaction.data.meta.postTokenBalances) {
+ if (held !== mint || owner === undefined || BigInt(uiTokenAmount.amount) < atLeast) continue
+ const wallet = Wallet.safeParse(await rpc('getAccountInfo', [owner, { encoding: 'base64' }]))
+ if (
+ !wallet.success ||
+ wallet.data.value.owner !== '11111111111111111111111111111111' ||
+ wallet.data.value.lamports < 50_000_000
+ ) {
+ continue
+ }
+ const balance = TokenBalance.safeParse(
+ await rpc('getTokenAccountBalance', [associatedTokenAccount(owner, mint)]).catch(
+ () => undefined
+ )
+ )
+ if (balance.success && BigInt(balance.data.value.amount) >= atLeast) return owner
+ }
+ }
+ throw new Error(`No usable holder of ${mint} among its recent traders.`)
+}
+
+describe.skipIf(SKIP)('staking pins on chain', () => {
+ it(
+ 'marinade: the program, state, mint and pool legs are what the code pins',
+ async () => {
+ const state = await readAccount(rpc, MARINADE_STATE)
+ expect(state.owner).toBe(MARINADE_PROGRAM)
+ const decoded = decodeMarinadeState(state.data)
+ expect(decoded?.msolMint).toBe(MSOL_MINT)
+ expect(decoded?.msolSupply).toBeGreaterThan(BigInt(0))
+ expect((await readAccount(rpc, MSOL_MINT)).owner).toBe(TOKEN_PROGRAM)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'jito: the pool is the stake pool program’s and holds this mint',
+ async () => {
+ const pool = await readAccount(rpc, JITO_STAKE_POOL)
+ expect(pool.owner).toBe(STAKE_POOL_PROGRAM)
+ const decoded = decodeStakePool(pool.data)
+ expect(decoded?.poolMint).toBe(JITOSOL_MINT)
+ expect(decoded?.tokenProgram).toBe(TOKEN_PROGRAM)
+ expect(decoded?.solDepositAuthority).toBeUndefined()
+ expect(decoded?.solWithdrawAuthority).toBeUndefined()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'solayer: the restaking pool holds these mints, and the stake pool mints the LP',
+ async () => {
+ const restaking = await readAccount(rpc, SOLAYER_RESTAKING_POOL)
+ expect(restaking.owner).toBe(SOLAYER_RESTAKING_PROGRAM)
+ expect(readAddress(restaking.data, 8)).toBe(SOLAYER_LP_MINT)
+ expect(readAddress(restaking.data, 40)).toBe(SSOL_MINT)
+ const pool = await readAccount(rpc, SOLAYER_STAKE_POOL)
+ expect(pool.owner).toBe(STAKE_POOL_PROGRAM)
+ const decoded = decodeStakePool(pool.data)
+ expect(decoded?.poolMint).toBe(SOLAYER_LP_MINT)
+ expect(decoded?.solDepositAuthority).toBeUndefined()
+ const list = await readAccount(rpc, decoded?.validatorList as string)
+ expect(decodeValidatorList(list.data)?.length).toBeGreaterThan(0)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('what the staking venues build runs on chain', () => {
+ const cases: [string, ChainVenue, string, string][] = [
+ ['marinade', createMarinadeVenue({ rpc }), 'mSOL', '0.1'],
+ ['jito', createJitoVenue({ rpc }), 'JitoSOL', '0.1'],
+ // Under Solana's minimum for a stake account, this venue refuses, so this asks for more.
+ ['solayer', createSolayerVenue({ rpc }), 'sSOL', '1'],
+ ]
+ const mints: Record = { mSOL: MSOL_MINT, JitoSOL: JITOSOL_MINT, sSOL: SSOL_MINT }
+
+ it.each(cases)(
+ '%s stakes SOL',
+ async (_id, venue, symbol) => {
+ const prepared = await venue.prepare(
+ { kind: 'stake', amount: '0.05', tokenIn: 'SOL' },
+ FUNDED
+ )
+ expect(() => venue.assertSafe(prepared.signing, FUNDED)).not.toThrow()
+ expect(prepared.receive?.symbol).toBe(symbol)
+ const own = associatedTokenAccount(FUNDED, mints[symbol] as string)
+ const before = await tokenAmount(own)
+ const result = await simulate(prepared, [own])
+ console.info(_id, 'stake units', result.unitsConsumed, prepared.receive?.amount)
+ expectRan(result)
+ // What the card said is what lands in the person's own account, to well inside 0.1%.
+ const after = readU64(bytesFromBase64(result.accounts?.[0]?.data[0] as string), 64)
+ const expected = baseUnits(prepared.receive?.amount)
+ const gained = after - before
+ const gap = gained > expected ? gained - expected : expected - gained
+ expect(gap).toBeLessThan(expected / BigInt(1000))
+ },
+ SLOW_MS
+ )
+
+ it.each(cases)(
+ '%s unstakes back to the person',
+ async (_id, venue, symbol, amount) => {
+ const atLeast = BigInt(Math.round(Number(amount) * 1_000_000_000))
+ const holder = await holderOf(mints[symbol] as string, atLeast)
+ const prepared = await venue.prepare({ kind: 'unstake', amount, tokenIn: symbol }, holder)
+ expect(() => venue.assertSafe(prepared.signing, holder)).not.toThrow()
+ const stakeAccount = /[1-9A-HJ-NP-Za-km-z]{32,44}/.exec(prepared.notes[0] ?? '')?.[0]
+ const result = await simulate(prepared, [stakeAccount ?? holder])
+ console.info(
+ _id,
+ 'unstake units',
+ result.unitsConsumed,
+ prepared.receive?.amount,
+ prepared.notes[0]
+ )
+ expectRan(result)
+ const seen = result.accounts?.[0]
+ const expected = baseUnits(prepared.receive?.amount)
+ if (stakeAccount === undefined) {
+ // SOL comes straight back to the wallet, less a network fee this bound more than covers.
+ const wallet = Wallet.parse(await rpc('getAccountInfo', [holder, { encoding: 'base64' }]))
+ const gained = BigInt(seen?.lamports as number) - BigInt(wallet.value.lamports)
+ expect(gained).toBeGreaterThan(expected - BigInt(200_000))
+ expect(gained).toBeLessThanOrEqual(expected + BigInt(1_000))
+ } else {
+ // A stake account the person alone controls, holding what the card said, already cooling.
+ const data = bytesFromBase64(seen?.data[0] as string)
+ expect(readAddress(data, 12)).toBe(holder)
+ expect(readAddress(data, 44)).toBe(holder)
+ const delegated = readU64(data, 156)
+ expect(delegated).toBeGreaterThan(expected - expected / BigInt(1000))
+ expect(readU64(data, 172)).not.toBe(BigInt('18446744073709551615'))
+ }
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts
index 35ee3c0d..6aefec37 100644
--- a/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts
+++ b/apps/dapp/lib/__tests__/solana-sui-race-quote.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentProposalResult } from '../agents/brain'
import { quoteChainRoutes, type ChainRouteQuote } from '../agents/chain-routes'
+import { racesFirst } from '../chains/parse-action'
import { planScore } from '../agents/plan-scoring'
import { applyChainMeasure, isUnmeasuredSwap } from '../agents/settle-chain-plan'
import { clearTokenCache } from '../chains/near/oneclick'
@@ -426,12 +427,13 @@ describe('the race on these chains', () => {
expect(buildSuiVenues(env).map((v) => v.id)).not.toContain('1click-sui')
})
- it('gives every venue on these chains a quote to race with, but a wallet transfer', () => {
+ it('gives every venue that races a quote, and none to one with no price to race on', () => {
const lulo = createLuloVenue({ apiKey: 'k' })
const env = { AUTH_SECRET: 'x'.repeat(48) }
for (const venue of [...buildSolanaVenues(env), lulo, ...buildSuiVenues(env)]) {
- if (venue.kinds.includes('send')) expect(venue.quote, venue.id).toBeUndefined()
- else expect(venue.quote, venue.id).toBeTypeOf('function')
+ const races = venue.kinds.some((kind) => racesFirst({ kind }))
+ if (races) expect(venue.quote, venue.id).toBeTypeOf('function')
+ else expect(venue.quote, venue.id).toBeUndefined()
}
})
@@ -456,7 +458,7 @@ describe('the race on these chains', () => {
it('says agents compete on Solana and Sui, and nothing these chains cannot do', () => {
const solana = capabilitySheet('solana')
expect(solana).toContain('Agents compete')
- expect(solana).not.toMatch(/Stake:|Send:|limit order/)
+ expect(solana).not.toMatch(/Send:|limit order/)
const onSui = capabilitySheet('sui')
expect(onSui).toContain('Agents compete')
expect(onSui).toContain('Stake:')
diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts
index c42f7f9d..309d92d9 100644
--- a/apps/dapp/lib/__tests__/solana-venues.test.ts
+++ b/apps/dapp/lib/__tests__/solana-venues.test.ts
@@ -75,6 +75,8 @@ describe('tokens', () => {
USDT: [USDT, 6],
JitoSOL: ['J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn', 9],
ONyc: [ONYC, 9],
+ mSOL: ['mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So', 9],
+ sSOL: ['sSo14endRuUbvQaJS3dq36Q829a3A6BEfoeeRGJywEh', 9],
})
})
@@ -83,16 +85,14 @@ describe('tokens', () => {
prepareChainAction(
solanaModule,
{ kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'USDC.e' },
- CAPTURE_ACCOUNT,
- {}
+ CAPTURE_ACCOUNT
)
).rejects.toThrow(/verified token/)
await expect(
prepareChainAction(
solanaModule,
{ kind: 'swap', amount: '1', tokenIn: USDC, tokenOut: 'SOL' },
- CAPTURE_ACCOUNT,
- {}
+ CAPTURE_ACCOUNT
)
).rejects.toThrow(/verified token/)
})
@@ -670,12 +670,25 @@ describe('module', () => {
'1click-solana',
'jupiter-lend',
'kamino',
+ 'kamino-lend',
+ 'jito',
+ 'marinade',
+ 'solayer',
+ 'streamflow',
'send',
])
})
- it('offers the three venues that need no key', () => {
- expect(buildSolanaVenues({}).map((v) => v.id)).toEqual(['jupiter', 'jupiter-lend', 'kamino'])
+ it('offers the venues that need no key', () => {
+ expect(buildSolanaVenues({}).map((v) => v.id)).toEqual([
+ 'jupiter',
+ 'jupiter-lend',
+ 'kamino',
+ 'kamino-lend',
+ 'jito',
+ 'marinade',
+ 'solayer',
+ ])
expect(solanaModule.defaultVenue).toMatchObject({ swap: 'jupiter', supply: 'jupiter-lend' })
expect(solanaModule.chain).toBe('solana')
})
@@ -688,30 +701,13 @@ describe('module', () => {
expect(kamino?.submit).toBeUndefined()
})
- it('refuses a trade over the $500 cap before building anything', async () => {
- const { calls } = stubFetch((url) =>
- url.includes('/price/v3') ? { body: { [USDC]: { usdPrice: 1 } } } : undefined
- )
- const chainApi = { ...solanaModule, priceUsd: async () => 1 }
- await expect(
- prepareChainAction(
- chainApi,
- { kind: 'swap', amount: '501', tokenIn: 'USDC', tokenOut: 'SOL' },
- CAPTURE_ACCOUNT,
- {}
- )
- ).rejects.toThrow(/caps each trade/)
- expect(calls).toHaveLength(0)
- })
-
it('refuses a named venue that cannot do this', async () => {
const chainApi = { ...solanaModule, priceUsd: async () => 1 }
await expect(
prepareChainAction(
chainApi,
{ kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SOL', venue: 'kamino' },
- CAPTURE_ACCOUNT,
- {}
+ CAPTURE_ACCOUNT
)
).rejects.toThrow(/can't swap here/)
})
@@ -733,8 +729,7 @@ describe('module', () => {
await prepareChainAction(
chainApi,
{ kind: 'supply', amount: '1', tokenIn: 'USDC', venue },
- CAPTURE_ACCOUNT,
- {}
+ CAPTURE_ACCOUNT
).catch(() => undefined)
}
expect(seen).toEqual(['kamino', 'jupiter-lend'])
@@ -743,8 +738,8 @@ describe('module', () => {
describe('venues page', () => {
it('lists every row of the report', () => {
- expect(solanaVenues).toHaveLength(15)
- expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(15)
+ expect(solanaVenues).toHaveLength(20)
+ expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(20)
for (const v of solanaVenues) {
expect(v.family).toBe('solana')
expect(v.chains).toEqual(['Solana'])
@@ -778,10 +773,11 @@ describe('venues page', () => {
})
describe('capabilities', () => {
- it('has one entry per wired kind, and each example parses and resolves', async () => {
+ it('names every wired kind, and each example parses and resolves', async () => {
const kinds = solanaCapabilities.map((c) => c.kind)
- expect(new Set(kinds)).toEqual(new Set(['swap', 'supply']))
- expect(kinds).toHaveLength(2)
+ expect(new Set(kinds)).toEqual(
+ new Set(['swap', 'supply', 'withdraw', 'borrow', 'repay', 'stake', 'unstake', 'claim'])
+ )
const swap = solanaCapabilities.find((c) => c.kind === 'swap')!
expect(parseChainAction(`swap 10 USDC to ${swap.symbol}`)).toMatchObject({
kind: 'swap',
diff --git a/apps/dapp/lib/__tests__/solayer.test.ts b/apps/dapp/lib/__tests__/solayer.test.ts
new file mode 100644
index 00000000..dc5ff4e8
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solayer.test.ts
@@ -0,0 +1,467 @@
+import { describe, expect, it } from 'vitest'
+
+import { associatedTokenAccount, decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import {
+ chooseValidator,
+ createSolayerVenue,
+ SOLAYER_LP_MINT,
+ SOLAYER_RESTAKING_POOL,
+ SOLAYER_RESTAKING_PROGRAM,
+ SOLAYER_STAKE_POOL,
+ SOLAYER_TOKENS,
+ SSOL_MINT,
+} from '../chains/solana/solayer'
+import { solayerListing } from '../chains/solana/solayer-listing'
+import {
+ addressWithSeed,
+ createStakeAccountWithSeed,
+ decodeValidatorList,
+ readU64,
+ STAKE_POOL_PROGRAM,
+ STAKE_PROGRAM,
+} from '../chains/solana/stake-pool'
+import type { PreparedAction } from '../chains/types'
+import {
+ addressBytes,
+ encodeStakePool,
+ fakeRpc,
+ ME,
+ payload,
+ rebuilt,
+ SOMEONE_ELSE,
+ transferOf,
+ u32Bytes,
+ u64Bytes,
+ UNKNOWN_PROGRAM,
+ unlimitedApproval,
+ withAccount,
+ withExtraData,
+ withOtherFunction,
+ type PoolFixture,
+} from './solana-stake-fixtures'
+
+const EPOCH = 1077
+const LIST = 'nk5E1Gc2rCuU2MDTRqdcQdiMfV9KnZ6JHykA1cTJQ56'
+const BIG_VOTE = 'SLaYv7tCwetrFGbPCRnqpHswG5qqKino78EYpbGF7xY'
+const SMALL_VOTE = 'CatzoSMUkTRidT5DwBxAC2pEtnwMBTpkCepHkFgZDiqb'
+/** Hro3G6fA... is the stake account a live withdrawal used for that validator on 4 November 2026. */
+const BIG_VALIDATOR_STAKE = 'Hro3G6fALTcz85Qwke3BKZEtK6UzhMYrqXMoUxPMoXz'
+
+const BASE: PoolFixture = {
+ poolMint: SOLAYER_LP_MINT,
+ reserveStake: 'Brh9rB6npnjM1vDXyCXtzkXVGRnsh6KHqmBz26tVACg9',
+ managerFeeAccount: 'ARs3HTD79nsaUdDKqfGhgbNMVJkXVdRs2EpHAm4LNEcq',
+ validatorList: LIST,
+ totalLamports: BigInt('82390512858986'),
+ poolTokenSupply: BigInt('69899353088216'),
+ lastUpdateEpoch: BigInt(EPOCH),
+ stakeWithdrawalFee: [BigInt(8), BigInt(10000)],
+}
+
+function validatorEntry(vote: string, active: bigint, status = 0): number[] {
+ return [
+ ...u64Bytes(active),
+ ...u64Bytes(BigInt(0)),
+ ...u64Bytes(BigInt(EPOCH)),
+ ...u64Bytes(BigInt(0)),
+ ...u32Bytes(0),
+ ...u32Bytes(0),
+ status,
+ ...addressBytes(vote),
+ ]
+}
+
+function encodeList(entries: number[][]): Uint8Array {
+ return Uint8Array.from([2, ...u32Bytes(100), ...u32Bytes(entries.length), ...entries.flat()])
+}
+
+const LIST_BYTES = encodeList([
+ validatorEntry(SMALL_VOTE, BigInt('1009574703')),
+ validatorEntry(BIG_VOTE, BigInt('82389651605762')),
+])
+
+function venueWith(
+ overrides: Partial = {},
+ options: { restakingOwner?: string; list?: Uint8Array } = {}
+) {
+ const rpc = fakeRpc({
+ epoch: EPOCH,
+ accounts: {
+ [SOLAYER_STAKE_POOL]: {
+ owner: STAKE_POOL_PROGRAM,
+ lamports: BigInt(1),
+ data: encodeStakePool({ ...BASE, ...overrides }),
+ },
+ [SOLAYER_RESTAKING_POOL]: {
+ owner: options.restakingOwner ?? SOLAYER_RESTAKING_PROGRAM,
+ lamports: BigInt(1),
+ data: new Uint8Array(74),
+ },
+ [LIST]: { owner: STAKE_POOL_PROGRAM, lamports: BigInt(1), data: options.list ?? LIST_BYTES },
+ },
+ })
+ return { venue: createSolayerVenue({ rpc }), rpc }
+}
+
+const stake = { kind: 'stake', amount: '1', tokenIn: 'SOL' } as const
+const unstake = { kind: 'unstake', amount: '1', tokenIn: 'sSOL' } as const
+
+const preparedStake = (): Promise => venueWith().venue.prepare(stake, ME)
+const preparedUnstake = (): Promise => venueWith().venue.prepare(unstake, ME)
+
+function refuses(action: () => unknown, pattern: RegExp): void {
+ expect(action).toThrow(pattern)
+}
+
+describe('solayer stake', () => {
+ it('reads the amount from the pool and shows no rate', async () => {
+ const prepared = await preparedStake()
+ expect(prepared.receive?.symbol).toBe('sSOL')
+ expect(Number(prepared.receive?.amount)).toBeCloseTo(0.84839, 4)
+ expect(prepared.apy).toBeUndefined()
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/not showing a yearly rate/)
+ expect(notes).toMatch(/3-of-5/)
+ expect(notes).toMatch(/\$520\.67M/)
+ expect(notes).toMatch(/about 82,390 SOL/)
+ expect(notes).toMatch(/Leaving is slow/)
+ expect(notes).toMatch(/at least 1 SOL worth of sSOL at once/)
+ })
+
+ it('restakes exactly the floor it asks the deposit for, with only the person signing', async () => {
+ const prepared = await preparedStake()
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ expect(decoded.signerCount).toBe(1)
+ expect(decoded.feePayer).toBe(ME)
+ const [deposit, restake] = decoded.instructions.slice(-2)
+ expect(decoded.staticAccounts[deposit!.programIndex]).toBe(STAKE_POOL_PROGRAM)
+ expect(readU64(deposit!.data, 1)).toBe(BigInt('1000000000'))
+ expect(decoded.staticAccounts[restake!.programIndex]).toBe(SOLAYER_RESTAKING_PROGRAM)
+ expect(readU64(restake!.data, 8)).toBe(readU64(deposit!.data, 9))
+ expect(decoded.staticAccounts[restake!.accounts[3]!]).toBe(
+ associatedTokenAccount(ME, SSOL_MINT)
+ )
+ })
+
+ it('passes its own check, and a quote builds nothing', async () => {
+ const { venue } = venueWith()
+ const prepared = await venue.prepare(stake, ME)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ const quoting = venueWith()
+ const quoted = await quoting.venue.quote?.(stake, ME)
+ expect(quoted?.receive?.symbol).toBe('sSOL')
+ expect(quoted?.apyPercent).toBeUndefined()
+ expect(quoting.rpc.calls).not.toContain('getLatestBlockhash')
+ })
+
+ it('refuses another token, a deposit that needs someone else, a stale pool, a wrong restaking owner', async () => {
+ await expect(venueWith().venue.prepare({ ...stake, tokenIn: 'sUSD' }, ME)).rejects.toThrow(
+ /takes SOL/
+ )
+ await expect(
+ venueWith({ solDepositAuthority: SOMEONE_ELSE }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/another signature/)
+ await expect(
+ venueWith({ lastUpdateEpoch: BigInt(EPOCH - 1) }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/still updating/)
+ await expect(
+ venueWith({}, { restakingOwner: SOMEONE_ELSE }).venue.prepare(stake, ME)
+ ).rejects.toThrow(/doesn’t look as expected/)
+ })
+})
+
+describe('solayer unstake', () => {
+ it('says plainly that this exit is delayed and how the person finishes it', async () => {
+ const prepared = await preparedUnstake()
+ expect(prepared.kind).toBe('unstake')
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'sSOL' })
+ expect(prepared.summary).toMatch(/cools down before you can withdraw/)
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/This exit is delayed/)
+ expect(notes).toMatch(/does not reach your balance now/)
+ expect(notes).toMatch(/say "claim SOL from Solayer"/)
+ expect(notes).toMatch(/0\.08%/)
+ expect(notes).toContain(addressWithSeed(ME, 'intent-stake-0', STAKE_PROGRAM))
+ })
+
+ it('needs only the person: the stake account comes from a seed, not a new keypair', async () => {
+ const prepared = await preparedUnstake()
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ expect(decoded.signerCount).toBe(1)
+ const stakeAccount = addressWithSeed(ME, 'intent-stake-0', STAKE_PROGRAM)
+ const [unrestake, create, withdraw, deactivate] = decoded.instructions.slice(-4)
+ expect(readU64(unrestake!.data, 8)).toBe(BigInt('1000000000'))
+ expect(decoded.staticAccounts[create!.accounts[1]!]).toBe(stakeAccount)
+ expect(readU64(withdraw!.data, 1)).toBe(BigInt('1000000000'))
+ expect(decoded.staticAccounts[withdraw!.accounts[3]!]).toBe(BIG_VALIDATOR_STAKE)
+ expect(decoded.staticAccounts[withdraw!.accounts[4]!]).toBe(stakeAccount)
+ expect(decoded.staticAccounts[withdraw!.accounts[5]!]).toBe(ME)
+ expect(decoded.staticAccounts[withdraw!.accounts[6]!]).toBe(ME)
+ expect(decoded.staticAccounts[deactivate!.accounts[0]!]).toBe(stakeAccount)
+ expect(decoded.staticAccounts[deactivate!.accounts[2]!]).toBe(ME)
+ expect(() => venueWith().venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('derives the address the chain did for a seed in a live transaction', () => {
+ // 4krfXaL9... on 4 November 2026 created this account from this base and seed.
+ expect(
+ addressWithSeed(
+ 'HH2mfuVsuVpLBT7poqvSbzXXUXtMxUB5HKmUa5F7e1bG',
+ 'solstk1793830450018',
+ STAKE_PROGRAM
+ )
+ ).toBe('DGTBCx2YJvGQEzFvT144ttKHoXkLAeh8FnpuZCxXDbjp')
+ })
+
+ it('picks the validator holding the most, unless the pool prefers one, and skips any not active', () => {
+ const validators = decodeValidatorList(
+ encodeList([
+ validatorEntry(SMALL_VOTE, BigInt(5)),
+ validatorEntry(BIG_VOTE, BigInt(9)),
+ validatorEntry(SOMEONE_ELSE, BigInt(100), 1),
+ ])
+ )!
+ expect(validators).toHaveLength(2)
+ expect(chooseValidator({ preferredWithdrawValidator: undefined }, validators)?.vote).toBe(
+ BIG_VOTE
+ )
+ expect(chooseValidator({ preferredWithdrawValidator: SMALL_VOTE }, validators)?.vote).toBe(
+ SMALL_VOTE
+ )
+ expect(
+ chooseValidator({ preferredWithdrawValidator: SOMEONE_ELSE }, validators)
+ ).toBeUndefined()
+ })
+
+ it('leaves the stake of an unstake of everything under the person alone, for exactly the balance', async () => {
+ const prepared = await venueWith().venue.prepare(
+ { ...unstake, amount: '2.500000001', wholePosition: true },
+ ME
+ )
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ const [unrestake, , withdraw] = decoded.instructions.slice(-4)
+ expect(readU64(unrestake!.data, 8)).toBe(BigInt('2500000001'))
+ expect(readU64(withdraw!.data, 1)).toBe(BigInt('2500000001'))
+ })
+
+ it('refuses less than Solana lets a stake account hold, and names the market instead', async () => {
+ await expect(venueWith().venue.prepare({ ...unstake, amount: '0.5' }, ME)).rejects.toThrow(
+ /under the 1 SOL Solana requires.*sell sSOL on the market/
+ )
+ })
+
+ it('refuses another token, more than the validator holds, and a pool with no validator', async () => {
+ await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'SOL' }, ME)).rejects.toThrow(
+ /takes sSOL/
+ )
+ await expect(venueWith().venue.prepare({ ...unstake, tokenIn: 'sUSD' }, ME)).rejects.toThrow(
+ /takes sSOL/
+ )
+ await expect(
+ venueWith({}, { list: encodeList([validatorEntry(BIG_VOTE, BigInt(10))]) }).venue.prepare(
+ unstake,
+ ME
+ )
+ ).rejects.toThrow(/can’t be withdrawn/)
+ await expect(
+ venueWith({}, { list: encodeList([]) }).venue.prepare(unstake, ME)
+ ).rejects.toThrow(/no validator/)
+ })
+})
+
+describe('solayer assertSafe', () => {
+ const venue = venueWith().venue
+
+ it('refuses the wrong signer', async () => {
+ const prepared = await preparedStake()
+ refuses(() => venue.assertSafe(prepared.signing, SOMEONE_ELSE), /network fee/)
+ refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rebuilt(prepared, ME)), ME), /network fee/)
+ })
+
+ it('refuses an unknown program', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const last = rest.at(-1)!
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ { ...last, programAddress: UNKNOWN_PROGRAM as never },
+ ]),
+ ME
+ ),
+ /should not need/
+ )
+ })
+
+ it('refuses an unknown function', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(payload(ME, [...rest.slice(0, -1), withOtherFunction(rest.at(-1)!)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses extra calldata', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(rest.at(-1)!)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses sSOL or LP tokens going to someone else', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ withAccount(rest.at(-1)!, 3, associatedTokenAccount(SOMEONE_ELSE, SSOL_MINT)),
+ ]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -2),
+ withAccount(rest.at(-2)!, 4, associatedTokenAccount(SOMEONE_ELSE, SOLAYER_LP_MINT)),
+ rest.at(-1)!,
+ ]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+
+ it('refuses a restake that does not match its deposit', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const last = rest.at(-1)!
+ const data = Uint8Array.from(last.data ?? [])
+ data[8] = (data[8] ?? 0) ^ 1
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME),
+ /doesn’t match its deposit/
+ )
+ })
+
+ it('refuses withdrawn stake going under someone else’s authority', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ const withdraw = rest.at(-2)!
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -2), withAccount(withdraw, 5, SOMEONE_ELSE), rest.at(-1)!]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+
+ it('refuses a stake account whose address the seed does not give, or that takes too much SOL', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ const create = rest.at(-3)!
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -3),
+ withAccount(create, 1, SOMEONE_ELSE),
+ ...rest.slice(-2),
+ ]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ const costly = createStakeAccountWithSeed(ME, 'intent-stake-0', BigInt('5000000001'))
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -3), costly, ...rest.slice(-2)]), ME),
+ /isn’t what Solayer should get/
+ )
+ })
+
+ it('refuses an exit that withdraws to a different stake account than it creates', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ const other = addressWithSeed(ME, 'intent-stake-1', STAKE_PROGRAM)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -2), withAccount(rest.at(-2)!, 4, other), rest.at(-1)!]),
+ ME
+ ),
+ /doesn’t fit together/
+ )
+ })
+
+ it('refuses a transfer or an unlimited approval added to it', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest, transferOf(ME, SOMEONE_ELSE, BigInt(1))]), ME),
+ /SOL transfer/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest,
+ unlimitedApproval(associatedTokenAccount(ME, SOLAYER_LP_MINT), SOMEONE_ELSE, ME),
+ ]),
+ ME
+ ),
+ /moves or approves tokens/
+ )
+ })
+
+ it('refuses a plain transfer where the stake account creation belongs', async () => {
+ const rest = rebuilt(await preparedUnstake(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -3),
+ transferOf(ME, SOMEONE_ELSE, BigInt(1)),
+ ...rest.slice(-2),
+ ]),
+ ME
+ ),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses the wrong token', async () => {
+ const rest = rebuilt(await preparedStake(), ME)
+ const usdc = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 4, usdc)]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+})
+
+describe('solayer tokens and listing', () => {
+ it('offers sSOL only', () => {
+ expect(SOLAYER_TOKENS).toEqual([
+ { symbol: 'sSOL', id: SSOL_MINT, decimals: 9, name: 'Solayer restaked SOL' },
+ ])
+ })
+
+ it('lists as executing on Solana and says what it leaves out', () => {
+ expect(solayerListing).toMatchObject({
+ id: 'solayer',
+ family: 'solana',
+ integration: 'executes',
+ })
+ expect(solayerListing.capability).toMatch(/not sUSD, Pay, the card or the chain/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/springsui-unstake.live.test.ts b/apps/dapp/lib/__tests__/springsui-unstake.live.test.ts
new file mode 100644
index 00000000..b9b41b02
--- /dev/null
+++ b/apps/dapp/lib/__tests__/springsui-unstake.live.test.ts
@@ -0,0 +1,73 @@
+import { fromBase64, normalizeSuiAddress } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import { SSUI_TYPE } from '../chains/sui/policies'
+import { springsuiVenue } from '../chains/sui/springsui'
+import { toBaseUnits } from '../chains/types'
+
+/**
+ * A real unsigned transaction against mainnet, read-only. Nothing here signs or sends: it is only
+ * built and dry-run, for a public address whose key nobody here has. When that address empties
+ * this fails on balance, so swap it for another funded one.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SSUI_HOLDER = '0xe4b732379e532578abd81ca8bbc40f213eed36941ab280205654059c8bfab56d'
+
+describe.skipIf(SKIP)('SpringSui unstake against mainnet', () => {
+ it('builds an unstake that passes its check, pays out, and dry-runs clean', async () => {
+ const prepared = await springsuiVenue.prepare(
+ { kind: 'unstake', amount: '2', tokenIn: 'sSUI' },
+ SSUI_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => springsuiVenue.assertSafe(prepared.signing, SSUI_HOLDER)).not.toThrow()
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { effects: true },
+ })
+ expect(result.Transaction?.effects?.status.success).toBe(true)
+ expect(prepared.receive?.symbol).toBe('SUI')
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(2)
+ }, 60_000)
+
+ it('reads sSUI in the wallet to the last unit, and "all" of it dry-runs clean', async () => {
+ const positions = await springsuiVenue.positions?.(SSUI_HOLDER)
+ expect(positions).toHaveLength(1)
+ const staked = positions?.[0]
+ expect(staked).toMatchObject({ slot: 'staked', symbol: 'sSUI' })
+
+ const { balance } = await suiClient().core.getBalance({
+ owner: SSUI_HOLDER,
+ coinType: SSUI_TYPE,
+ })
+ expect(toBaseUnits(staked?.amount as string, 9)).toBe(balance.balance)
+
+ const prepared = await springsuiVenue.prepare(
+ { kind: 'unstake', amount: staked?.amount as string, wholePosition: true, tokenIn: 'sSUI' },
+ SSUI_HOLDER
+ )
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(() => springsuiVenue.assertSafe(prepared.signing, SSUI_HOLDER)).not.toThrow()
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { effects: true, balanceChanges: true },
+ })
+ expect(result.Transaction?.effects?.status.success).toBe(true)
+ const paid = result.Transaction?.balanceChanges?.find((c) =>
+ c.coinType.endsWith('::SPRING_SUI')
+ )
+ expect(paid?.amount).toBe(`-${balance.balance}`)
+ }, 60_000)
+
+ it('finds nothing for an empty wallet', async () => {
+ expect(await springsuiVenue.positions?.(normalizeSuiAddress('0x1234'))).toEqual([])
+ }, 30_000)
+
+ it('sSUI still has the decimals in its token entry', async () => {
+ const { coinMetadata } = await suiClient().core.getCoinMetadata({ coinType: SSUI_TYPE })
+ expect(coinMetadata?.symbol).toBe('sSUI')
+ expect(coinMetadata?.decimals).toBe(9)
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/springsui-unstake.test.ts b/apps/dapp/lib/__tests__/springsui-unstake.test.ts
new file mode 100644
index 00000000..902d6154
--- /dev/null
+++ b/apps/dapp/lib/__tests__/springsui-unstake.test.ts
@@ -0,0 +1,316 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Inputs, Transaction } from '@mysten/sui/transactions'
+import { toBase64 } from '@mysten/sui/utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { CAPTURED_SENDER, CAPTURED_SPRINGSUI_STAKE } from '../chains/sui/captured-transactions'
+import { SPRINGSUI_PACKAGE, SSUI_INFO, SSUI_TYPE, SSUI_WEIGHT_HOOK } from '../chains/sui/policies'
+import { springsuiVenue } from '../chains/sui/springsui'
+import { SPRINGSUI_TOKENS } from '../chains/sui/springsui-unstake'
+import { ChainActionRefused } from '../chains/types'
+
+const ME = '0x1111111111111111111111111111111111111111111111111111111111111111'
+const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead'
+const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01'
+const DIGEST = '11111111111111111111111111111111'
+const OTHER_LST = '0x460c669acd3f294dc4247a6877ec2532340ffde76162ab201e72fe95355830e7::asui::ASUI'
+
+const mocks = vi.hoisted(() => ({
+ simulate: vi.fn(),
+ getObjects: vi.fn(),
+ getBalance: vi.fn(),
+ built: [] as Transaction[],
+ bytes: '',
+}))
+
+vi.mock('../chains/sui/client', async (original) => ({
+ ...(await original()),
+ suiClient: () => ({
+ core: {
+ simulateTransaction: mocks.simulate,
+ getObjects: mocks.getObjects,
+ getBalance: mocks.getBalance,
+ },
+ }),
+ buildUnsigned: async (tx: Transaction) => {
+ mocks.built.push(tx)
+ return mocks.bytes
+ },
+}))
+
+function shared(tx: Transaction, objectId: string) {
+ return tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion: 1, mutable: true }))
+}
+
+async function built(edit: (tx: Transaction) => void, sender: string = ME): Promise {
+ const tx = new Transaction()
+ tx.setSender(sender)
+ tx.setGasOwner(sender)
+ tx.setGasPrice(1000)
+ tx.setGasBudget(10_000_000)
+ tx.setGasPayment([{ objectId: FOREIGN, version: 1, digest: DIGEST }])
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+interface Options {
+ coinType?: string
+ info?: string
+ to?: string
+}
+
+function goodRedeem(tx: Transaction, options: Options = {}): void {
+ const coinType = options.coinType ?? SSUI_TYPE
+ const info = options.info ?? SSUI_INFO
+ const [sui] = tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::liquid_staking::redeem`,
+ typeArguments: [coinType],
+ arguments: [
+ shared(tx, info),
+ tx.object(Inputs.ObjectRef({ objectId: THIEF, version: 1, digest: DIGEST })),
+ shared(tx, '0x5'),
+ ],
+ })
+ tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::weight::rebalance`,
+ typeArguments: [coinType],
+ arguments: [shared(tx, SSUI_WEIGHT_HOOK), shared(tx, '0x5'), shared(tx, info)],
+ })
+ tx.transferObjects([sui as never], options.to ?? ME)
+}
+
+function check(transaction: string, account: string = ME): void {
+ springsuiVenue.assertSafe({ family: 'sui', transaction }, account)
+}
+
+const u64Coin = (n: string) =>
+ bcs
+ .struct('Coin', { id: bcs.Address, value: bcs.u64() })
+ .serialize({ id: FOREIGN, value: n })
+ .toBytes()
+
+beforeEach(() => {
+ mocks.simulate.mockReset()
+ mocks.getObjects.mockReset()
+ mocks.built.length = 0
+ mocks.getObjects.mockResolvedValue({
+ objects: [{ json: { fee_config: { element: { redeem_fee_bps: '2' } } } }],
+ })
+})
+
+describe('SpringSui unstake safety on what the wallet would sign', () => {
+ it('passes the redeem it builds', async () => {
+ await expect(built(goodRedeem).then(check)).resolves.toBeUndefined()
+ })
+
+ it('still passes the stake it built before', () => {
+ expect(() =>
+ springsuiVenue.assertSafe(
+ { family: 'sui', transaction: CAPTURED_SPRINGSUI_STAKE },
+ CAPTURED_SENDER
+ )
+ ).not.toThrow()
+ })
+
+ it('refuses a redeem and a mint in one transaction', async () => {
+ const transaction = await built((tx) => {
+ goodRedeem(tx)
+ tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::liquid_staking::mint`,
+ typeArguments: [SSUI_TYPE],
+ })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it('refuses the wrong account', async () => {
+ const transaction = await built(goodRedeem)
+ expect(() => check(transaction, THIEF)).toThrow(/different account/)
+ })
+
+ it('refuses an unknown contract', async () => {
+ const transaction = await built((tx) => {
+ goodRedeem(tx)
+ tx.moveCall({ target: `${FOREIGN}::drain::everything` })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ })
+
+ it.each(['custom_redeem', 'custom_redeem_request', 'collect_fees', 'update_fees'])(
+ 'refuses %s, which this flow never uses',
+ async (fn) => {
+ const transaction = await built((tx) => {
+ goodRedeem(tx)
+ tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::liquid_staking::${fn}`,
+ typeArguments: [SSUI_TYPE],
+ })
+ })
+ expect(() => check(transaction)).toThrow(/confirmed/)
+ }
+ )
+
+ it('refuses extra steps', async () => {
+ const transaction = await built((tx) => {
+ goodRedeem(tx)
+ tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] })
+ })
+ expect(() => check(transaction)).toThrow(/Publish/)
+ })
+
+ it('refuses the SUI paid to someone else', async () => {
+ const transaction = await built((tx) => goodRedeem(tx, { to: THIEF }))
+ expect(() => check(transaction)).toThrow(/another address/)
+ })
+
+ it('refuses a token other than sSUI', async () => {
+ const transaction = await built((tx) => goodRedeem(tx, { coinType: OTHER_LST }))
+ expect(() => check(transaction)).toThrow(/sSUI/)
+ })
+
+ it('refuses a pool other than sSUI’s', async () => {
+ const transaction = await built((tx) => goodRedeem(tx, { info: FOREIGN }))
+ expect(() => check(transaction)).toThrow(/object/)
+ })
+
+ it('refuses a rebalance against another hook', async () => {
+ const transaction = await built((tx) => {
+ goodRedeem(tx)
+ tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::weight::rebalance`,
+ typeArguments: [SSUI_TYPE],
+ arguments: [shared(tx, FOREIGN), shared(tx, '0x5'), shared(tx, SSUI_INFO)],
+ })
+ })
+ expect(() => check(transaction)).toThrow(/object/)
+ })
+
+ it('refuses a payload that is not a Sui transaction', () => {
+ expect(() => springsuiVenue.assertSafe({ family: 'solana', transaction: 'AA==' }, ME)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => check('not base64 at all!')).toThrow(ChainActionRefused)
+ })
+})
+
+describe('building a SpringSui unstake', () => {
+ it('refuses a token that is not sSUI, and says how to name the right app', async () => {
+ await expect(
+ springsuiVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'haSUI' }, ME)
+ ).rejects.toThrow(/name it/)
+ expect(mocks.built).toHaveLength(0)
+ })
+
+ it('refuses a bad account', async () => {
+ await expect(
+ springsuiVenue.prepare({ kind: 'unstake', amount: '1', tokenIn: 'sSUI' }, 'nope')
+ ).rejects.toThrow(/Sui address/)
+ })
+
+ it('reads the SUI from the coin returned, and the fee it read', async () => {
+ mocks.bytes = await built(goodRedeem)
+ mocks.simulate.mockResolvedValue({
+ Transaction: { balanceChanges: [] },
+ commandResults: [{ returnValues: [{ bcs: u64Coin('2075784416') }] }],
+ })
+ const prepared = await springsuiVenue.prepare(
+ { kind: 'unstake', amount: '2', tokenIn: 'sSUI' },
+ ME
+ )
+ expect(prepared.kind).toBe('unstake')
+ expect(prepared.pay).toEqual({ amount: '2', symbol: 'sSUI' })
+ expect(prepared.receive).toEqual({ amount: '2.075784', symbol: 'SUI', approximate: true })
+ expect(prepared.notes.join(' ')).toMatch(/no wait/)
+ expect(prepared.notes.join(' ')).toMatch(/0.02%/)
+ const targets = mocks.built[0]
+ ?.getData()
+ .commands.flatMap((c) => (c.$kind === 'MoveCall' ? [c.MoveCall.function] : []))
+ expect(targets).toEqual(['redeem', 'rebalance'])
+ })
+
+ it('refuses when the dry run shows no payout', async () => {
+ mocks.bytes = await built(goodRedeem)
+ mocks.simulate.mockResolvedValue({ Transaction: {}, commandResults: [] })
+ await expect(
+ springsuiVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'sSUI' }, ME)
+ ).rejects.toThrow(/couldn't confirm/)
+ })
+
+ it('refuses when the fee cannot be read', async () => {
+ mocks.bytes = await built(goodRedeem)
+ mocks.simulate.mockResolvedValue({
+ Transaction: {},
+ commandResults: [{ returnValues: [{ bcs: u64Coin('2075784416') }] }],
+ })
+ mocks.getObjects.mockResolvedValue({ objects: [{ json: {} }] })
+ await expect(
+ springsuiVenue.prepare({ kind: 'unstake', amount: '2', tokenIn: 'sSUI' }, ME)
+ ).rejects.toThrow(/fee/)
+ })
+})
+
+describe('what SpringSui positions report', () => {
+ beforeEach(() => {
+ mocks.getBalance.mockReset()
+ })
+
+ it('counts sSUI in the wallet as staked, to the last unit', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: { balance: '1234567891' } })
+ expect(await springsuiVenue.positions?.(ME)).toEqual([
+ { slot: 'staked', symbol: 'sSUI', amount: '1.234567891' },
+ ])
+ expect(mocks.getBalance).toHaveBeenCalledWith({ owner: ME, coinType: SSUI_TYPE })
+ })
+
+ it('reads a real zero as nothing there', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: { balance: '0' } })
+ expect(await springsuiVenue.positions?.(ME)).toEqual([])
+ })
+
+ it('throws when it can not read, never an empty list', async () => {
+ mocks.getBalance.mockRejectedValue(new Error('rpc down'))
+ await expect(springsuiVenue.positions?.(ME)).rejects.toThrow('rpc down')
+ })
+
+ it('throws on a balance that is no number', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: {} })
+ await expect(springsuiVenue.positions?.(ME)).rejects.toThrow(/unreadable/)
+ })
+
+ it('throws on a malformed account', async () => {
+ await expect(springsuiVenue.positions?.('nope')).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('empties the whole position to the last unit', async () => {
+ mocks.bytes = await built(goodRedeem)
+ mocks.simulate.mockResolvedValue({
+ Transaction: { balanceChanges: [] },
+ commandResults: [{ returnValues: [{ bcs: u64Coin('2075784416') }] }],
+ })
+ await springsuiVenue.prepare(
+ { kind: 'unstake', amount: '1.234567891', wholePosition: true, tokenIn: 'sSUI' },
+ ME
+ )
+ const coin = mocks.built[0]?.getData().commands[0]
+ expect(coin?.$kind).toBe('$Intent')
+ expect(coin?.$kind === '$Intent' ? coin.$Intent.data['balance'] : undefined).toBe(
+ BigInt('1234567891')
+ )
+ })
+})
+
+describe('the venue as a whole', () => {
+ it('does both, and follows a swap only to stake', async () => {
+ expect(springsuiVenue.kinds).toEqual(['stake', 'unstake'])
+ await expect(
+ springsuiVenue.assertCanFollow?.({ kind: 'stake', tokenIn: 'SUI' }, ME)
+ ).resolves.toBeUndefined()
+ await expect(
+ springsuiVenue.assertCanFollow?.({ kind: 'unstake', tokenIn: 'sSUI' }, ME)
+ ).rejects.toThrow(/only stake/)
+ })
+
+ it('lists sSUI with the decimals its metadata gives', () => {
+ expect(SPRINGSUI_TOKENS).toEqual([expect.objectContaining({ symbol: 'sSUI', decimals: 9 })])
+ })
+})
diff --git a/apps/dapp/lib/__tests__/stake-claim.live.test.ts b/apps/dapp/lib/__tests__/stake-claim.live.test.ts
new file mode 100644
index 00000000..dcbff1ff
--- /dev/null
+++ b/apps/dapp/lib/__tests__/stake-claim.live.test.ts
@@ -0,0 +1,302 @@
+import { describe, expect, it } from 'vitest'
+import { z } from 'zod'
+
+import { bytesFromBase64 } from '../chains/base64'
+import { associatedTokenAccount, SYSTEM_PROGRAM, TOKEN_PROGRAM } from '../chains/solana/assert-safe'
+import { createSolayerVenue, SOLAYER_STAKE_POOL, SSOL_MINT } from '../chains/solana/solayer'
+import { createJitoVenue } from '../chains/solana/jito'
+import { createMarinadeVenue } from '../chains/solana/marinade'
+import {
+ compileForSigning,
+ computeBudget,
+ readAccount,
+ readAccounts,
+ readAddress,
+ readU64,
+ readOnlyRpc,
+ stakeExitAddress,
+ STAKE_HISTORY_SYSVAR,
+ STAKE_POOL_PROGRAM,
+ STAKE_PROGRAM,
+ type SolanaRpc,
+} from '../chains/solana/stake-pool'
+import {
+ decodeStakeAccount,
+ decodeStakeHistory,
+ stakeStillCooling,
+ withdrawStakeAccount,
+} from '../chains/solana/stake-claim'
+
+/**
+ * Read-only. Runs the claim's Withdraw against real stake accounts that real Solayer exits left on
+ * mainnet, with signature checks off. Nothing is signed or sent. The owners are whoever last left
+ * the pool, so which accounts turn up changes with each run.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const SLOW_MS = 300_000
+const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+
+const baseRpc = readOnlyRpc({
+ ...process.env,
+ SOLANA_RPC_URL: process.env['SOLANA_RPC_URL'] ?? 'https://api.mainnet-beta.solana.com',
+})
+const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms))
+
+const rpc: SolanaRpc = async (method, params) => {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ await wait(400)
+ return await baseRpc(method, params)
+ } catch (e) {
+ if (attempt >= 4) throw e
+ await wait(5_000)
+ }
+ }
+}
+
+const Signatures = z.array(z.object({ signature: z.string(), err: z.unknown().nullable() }))
+const Transaction = z.object({
+ transaction: z.object({
+ message: z.object({
+ accountKeys: z.array(z.object({ pubkey: z.string() })),
+ instructions: z.array(
+ z.object({
+ programId: z.string(),
+ accounts: z.array(z.string()).optional(),
+ data: z.string().optional(),
+ })
+ ),
+ }),
+ }),
+})
+const Epoch = z.object({ epoch: z.number() })
+const Simulated = z.object({
+ value: z.object({
+ err: z.unknown().nullable(),
+ unitsConsumed: z.number().optional(),
+ accounts: z
+ .array(z.object({ lamports: z.number() }).nullable())
+ .nullable()
+ .optional(),
+ }),
+})
+
+/** Stake accounts that recent withdrawals from Solayer's pool sent somewhere. */
+async function stakeAccountsLeftByExits(limit: number): Promise {
+ const signatures = Signatures.parse(
+ await rpc('getSignaturesForAddress', [SOLAYER_STAKE_POOL, { limit }])
+ )
+ const found = new Set()
+ for (const { signature, err } of signatures) {
+ if (err !== null) continue
+ const transaction = Transaction.safeParse(
+ await rpc('getTransaction', [
+ signature,
+ { encoding: 'jsonParsed', maxSupportedTransactionVersion: 1 },
+ ])
+ )
+ if (!transaction.success) continue
+ for (const instruction of transaction.data.transaction.message.instructions) {
+ if (instruction.programId !== STAKE_POOL_PROGRAM || instruction.accounts === undefined) {
+ continue
+ }
+ const stake = instruction.accounts[4]
+ if (stake !== undefined && instruction.accounts.length === 13) found.add(stake)
+ }
+ }
+ return [...found]
+}
+
+const SimulatedStake = z.object({
+ value: z.object({
+ err: z.unknown().nullable(),
+ unitsConsumed: z.number().optional(),
+ accounts: z
+ .array(z.object({ data: z.tuple([z.string(), z.string()]) }).nullable())
+ .nullable()
+ .optional(),
+ }),
+})
+
+const TokenAccounts = z.array(
+ z.object({
+ pubkey: z.string(),
+ account: z.object({ data: z.tuple([z.string(), z.string()]) }),
+ })
+)
+
+/**
+ * Wallets holding at least this much of the token in their own account, with SOL for the fee. The
+ * public nodes refuse the largest-holders call, and one answered this search on 5 November 2026.
+ */
+async function holdersOf(mint: string, atLeast: bigint): Promise {
+ const listed = TokenAccounts.parse(
+ await rpc('getProgramAccounts', [
+ TOKEN_PROGRAM,
+ {
+ encoding: 'base64',
+ dataSlice: { offset: 32, length: 40 },
+ filters: [{ dataSize: 165 }, { memcmp: { offset: 0, bytes: mint } }],
+ },
+ ])
+ )
+ const holders: string[] = []
+ for (const { pubkey, account } of listed) {
+ if (holders.length >= 3) break
+ const data = bytesFromBase64(account.data[0])
+ const owner = readAddress(data, 0)
+ if (readU64(data, 32) < atLeast || pubkey !== associatedTokenAccount(owner, mint)) continue
+ const wallet = await readAccount(rpc, owner).catch(() => undefined)
+ if (wallet?.owner === SYSTEM_PROGRAM && wallet.lamports > BigInt(50_000_000)) {
+ holders.push(owner)
+ }
+ }
+ return holders
+}
+
+describe.skipIf(SKIP)('an unstake the claim can find later', () => {
+ it(
+ 'lands at the first slot, under the person alone, already cooling',
+ async () => {
+ const holders = await holdersOf(SSOL_MINT, BigInt(2_000_000_000))
+ console.info('holders', holders.length)
+ expect(holders.length).toBeGreaterThan(0)
+ const venue = createSolayerVenue({ rpc })
+ let ran = 0
+ for (const holder of holders.slice(0, 3)) {
+ const prepared = await venue.prepare(
+ { kind: 'unstake', amount: '1.5', tokenIn: 'sSOL' },
+ holder
+ )
+ expect(() => venue.assertSafe(prepared.signing, holder)).not.toThrow()
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const stakeAccount = stakeExitAddress(holder, 0)
+ expect(prepared.notes[0]).toContain(stakeAccount)
+ const { value } = SimulatedStake.parse(
+ await rpc('simulateTransaction', [
+ prepared.signing.transaction,
+ {
+ encoding: 'base64',
+ sigVerify: false,
+ replaceRecentBlockhash: true,
+ commitment: 'confirmed',
+ accounts: { encoding: 'base64', addresses: [stakeAccount] },
+ },
+ ])
+ )
+ console.info(holder, 'err', JSON.stringify(value.err), 'units', value.unitsConsumed)
+ if (value.err !== null) continue
+ ran += 1
+ const state = decodeStakeAccount(bytesFromBase64(value.accounts?.[0]?.data[0] as string))
+ expect(state?.withdrawer).toBe(holder)
+ expect(state?.lockedUp).toBe(false)
+ const { epoch } = Epoch.parse(await rpc('getEpochInfo', [{ commitment: 'confirmed' }]))
+ expect(state?.deactivationEpoch).toBe(BigInt(epoch))
+ }
+ expect(ran).toBeGreaterThan(0)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('claiming a cooled stake account', () => {
+ it(
+ 'the chain agrees with what this reads as ready or cooling, and pays the whole balance',
+ async () => {
+ const { epoch } = Epoch.parse(await rpc('getEpochInfo', [{ commitment: 'confirmed' }]))
+ const history = decodeStakeHistory((await readAccount(rpc, STAKE_HISTORY_SYSVAR)).data)
+ expect(history?.size).toBeGreaterThan(100)
+ const addresses = await stakeAccountsLeftByExits(200)
+ expect(addresses.length).toBeGreaterThan(0)
+ const held = await readAccounts(rpc, addresses)
+
+ const seen = { ready: 0, cooling: 0 }
+ for (const [i, account] of held.entries()) {
+ const address = addresses[i] as string
+ if (account === undefined || account.owner !== STAKE_PROGRAM) continue
+ const state = decodeStakeAccount(account.data)
+ if (state === undefined || state.lockedUp || history === undefined) continue
+ const NOT_DEACTIVATED = BigInt('18446744073709551615')
+ if (state.deactivationEpoch === NOT_DEACTIVATED) continue
+ const left = stakeStillCooling(state.stake, state.deactivationEpoch, BigInt(epoch), history)
+ const owner = state.withdrawer
+ const transaction = await compileForSigning(rpc, owner, [
+ ...computeBudget(20_000),
+ withdrawStakeAccount(owner, address, account.lamports),
+ ])
+ const wallet = await readAccount(rpc, owner).catch(() => undefined)
+ if (wallet === undefined || wallet.lamports < BigInt(100_000)) continue
+ const { value } = Simulated.parse(
+ await rpc('simulateTransaction', [
+ transaction,
+ {
+ encoding: 'base64',
+ sigVerify: false,
+ replaceRecentBlockhash: true,
+ commitment: 'confirmed',
+ accounts: { encoding: 'base64', addresses: [owner, address] },
+ },
+ ])
+ )
+ console.info(
+ address,
+ 'deactivated',
+ state.deactivationEpoch,
+ 'epoch',
+ epoch,
+ 'left',
+ left,
+ 'err',
+ JSON.stringify(value.err),
+ 'units',
+ value.unitsConsumed
+ )
+ if (left === BigInt(0)) {
+ seen.ready += 1
+ expect(value.err).toBeNull()
+ expect(value.unitsConsumed ?? 0).toBeLessThan(20_000)
+ // The account closes, and the wallet gains its whole balance less the network fee.
+ const [after, closed] = value.accounts ?? []
+ expect(closed?.lamports ?? 0).toBe(0)
+ const gained = BigInt(after?.lamports ?? 0) - wallet.lamports
+ expect(gained).toBeGreaterThan(account.lamports - BigInt(100_000))
+ expect(gained).toBeLessThanOrEqual(account.lamports)
+ } else {
+ seen.cooling += 1
+ expect(value.err).not.toBeNull()
+ }
+ }
+ console.info('seen', seen, 'of', addresses.length)
+ expect(seen.ready + seen.cooling).toBeGreaterThan(0)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'finds no waiting exits for a wallet with none, without an account index',
+ async () => {
+ // The default public node, which refuses getProgramAccounts.
+ const publicNode = readOnlyRpc({ SOLANA_RPC_URL: 'https://solana-rpc.publicnode.com' })
+ const positions = await createSolayerVenue({ rpc: publicNode }).positions?.(FUNDED)
+ expect(positions?.filter((p) => p.slot === 'claimable' || p.slot === 'cooling')).toEqual([])
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads a real wallet’s staking tokens without failing',
+ async () => {
+ const publicNode = readOnlyRpc({ SOLANA_RPC_URL: 'https://solana-rpc.publicnode.com' })
+ for (const venue of [
+ createJitoVenue({ rpc: publicNode }),
+ createMarinadeVenue({ rpc: publicNode }),
+ ]) {
+ const positions = await venue.positions?.(FUNDED)
+ console.info(venue.id, positions)
+ expect(Array.isArray(positions)).toBe(true)
+ }
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/stake-claim.test.ts b/apps/dapp/lib/__tests__/stake-claim.test.ts
new file mode 100644
index 00000000..b78ec78b
--- /dev/null
+++ b/apps/dapp/lib/__tests__/stake-claim.test.ts
@@ -0,0 +1,568 @@
+import type { Instruction } from '@solana/kit'
+import { describe, expect, it } from 'vitest'
+
+import { decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import {
+ addressWithSeed,
+ createStakeAccountWithSeed,
+ readU64,
+ STAKE_EXIT_SLOTS,
+ stakeExitAddress,
+ STAKE_PROGRAM,
+} from '../chains/solana/stake-pool'
+import {
+ decodeStakeAccount,
+ decodeStakeHistory,
+ MAX_CLAIMS_AT_ONCE,
+ stakeStillCooling,
+} from '../chains/solana/stake-claim'
+import {
+ createSolayerVenue,
+ SOLAYER_LP_MINT,
+ SOLAYER_RESTAKING_POOL,
+ SOLAYER_RESTAKING_PROGRAM,
+ SOLAYER_STAKE_POOL,
+} from '../chains/solana/solayer'
+import type { ChainActionRequest, PreparedAction } from '../chains/types'
+import {
+ addressBytes,
+ encodeStakeAccount,
+ encodeStakeHistory,
+ encodeStakePool,
+ fakeRpc,
+ ME,
+ NEVER_DEACTIVATED,
+ payload,
+ rebuilt,
+ SOMEONE_ELSE,
+ transferOf,
+ u32Bytes,
+ u64Bytes,
+ UNKNOWN_PROGRAM,
+ withAccount,
+ withExtraData,
+ type FakeAccount,
+ type StakeAccountFixture,
+} from './solana-stake-fixtures'
+
+const EPOCH = 1078
+const HISTORY_SYSVAR = 'SysvarStakeHistory1111111111111111111111111'
+const CLOCK_SYSVAR = 'SysvarC1ock11111111111111111111111111111111'
+const COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111'
+const VOTE = 'SLaYv7tCwetrFGbPCRnqpHswG5qqKino78EYpbGF7xY'
+const LIST = 'nk5E1Gc2rCuU2MDTRqdcQdiMfV9KnZ6JHykA1cTJQ56'
+
+const DELEGATED = BigInt('2000000000')
+const RENT = BigInt('1666240')
+const HELD = DELEGATED + RENT
+
+/** The cluster's own figures for epoch 1076, read from mainnet on 5 November 2026. */
+const QUIET = {
+ effective: BigInt('434700524078108143'),
+ activating: BigInt('1279206833842960'),
+ deactivating: BigInt('3005599698567390'),
+}
+
+const HISTORY = encodeStakeHistory([
+ { epoch: BigInt(1077), ...QUIET },
+ { epoch: BigInt(1076), ...QUIET },
+])
+
+function chainAccounts(
+ exits: Record & { lamports?: bigint; owner?: string }>
+): Record {
+ const accounts: Record = {
+ [HISTORY_SYSVAR]: {
+ owner: 'Sysvar1111111111111111111111111111111111111',
+ lamports: BigInt(1),
+ data: HISTORY,
+ },
+ }
+ for (const [slot, exit] of Object.entries(exits)) {
+ const { lamports, owner, ...stake } = exit
+ accounts[stakeExitAddress(ME, Number(slot))] = {
+ owner: owner ?? STAKE_PROGRAM,
+ lamports: lamports ?? HELD,
+ data: encodeStakeAccount({
+ withdrawer: ME,
+ voter: VOTE,
+ delegated: DELEGATED,
+ deactivationEpoch: BigInt(EPOCH - 1),
+ ...stake,
+ }),
+ }
+ }
+ return accounts
+}
+
+function venueOver(
+ exits: Parameters[0],
+ options: {
+ epoch?: number
+ slotIndex?: number
+ simulationError?: unknown
+ unreadable?: string[]
+ } = {}
+) {
+ const rpc = fakeRpc({ epoch: options.epoch ?? EPOCH, accounts: chainAccounts(exits), ...options })
+ return { venue: createSolayerVenue({ rpc }), rpc }
+}
+
+const CLAIM: ChainActionRequest = { kind: 'claim', amount: 'all', tokenIn: 'SOL' }
+
+function callsOf(prepared: PreparedAction) {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ return {
+ decoded,
+ calls: decoded.instructions
+ .map((instruction) => ({
+ program: decoded.staticAccounts[instruction.programIndex],
+ accounts: instruction.accounts.map((index) => decoded.staticAccounts[index]),
+ data: instruction.data,
+ }))
+ .filter(({ program }) => program !== COMPUTE_BUDGET),
+ }
+}
+
+function refuses(action: () => unknown, pattern: RegExp): void {
+ expect(action).toThrow(pattern)
+}
+
+describe('reading a stake account', () => {
+ it('finds the fields at the offsets the stake program uses', () => {
+ const decoded = decodeStakeAccount(
+ encodeStakeAccount({
+ withdrawer: ME,
+ staker: SOMEONE_ELSE,
+ delegated: DELEGATED,
+ deactivationEpoch: BigInt(1077),
+ })
+ )
+ expect(decoded).toEqual({
+ delegated: true,
+ withdrawer: ME,
+ lockedUp: false,
+ stake: DELEGATED,
+ deactivationEpoch: BigInt(1077),
+ })
+ })
+
+ it('sees any lockup, by time, by epoch or by custodian', () => {
+ const locked = (change: Partial): boolean | undefined =>
+ decodeStakeAccount(encodeStakeAccount({ withdrawer: ME, ...change }))?.lockedUp
+ expect(locked({ lockupTimestamp: BigInt(1) })).toBe(true)
+ expect(locked({ lockupEpoch: BigInt(1) })).toBe(true)
+ expect(locked({ custodian: SOMEONE_ELSE })).toBe(true)
+ expect(locked({})).toBe(false)
+ })
+
+ it('takes no account of the wrong size or an uninitialized one', () => {
+ expect(decodeStakeAccount(new Uint8Array(199))).toBeUndefined()
+ expect(decodeStakeAccount(encodeStakeAccount({ withdrawer: ME, tag: 0 }))).toBeUndefined()
+ expect(decodeStakeAccount(encodeStakeAccount({ withdrawer: ME, tag: 3 }))).toBeUndefined()
+ })
+})
+
+describe('how much stake is still cooling', () => {
+ const history = decodeStakeHistory(HISTORY)!
+
+ it('cools in full the epoch after it stopped, when the network is quiet', () => {
+ expect(stakeStillCooling(DELEGATED, BigInt(1077), BigInt(1078), history)).toBe(BigInt(0))
+ })
+
+ it('has not cooled at all in the epoch it stopped', () => {
+ expect(stakeStillCooling(DELEGATED, BigInt(1078), BigInt(1078), history)).toBe(DELEGATED)
+ })
+
+ it('cools only in part when the whole network is leaving at once', () => {
+ const crowded = decodeStakeHistory(
+ encodeStakeHistory([
+ {
+ epoch: BigInt(1077),
+ effective: BigInt(1000),
+ activating: BigInt(0),
+ deactivating: BigInt(1000),
+ },
+ ])
+ )!
+ // 9% of the effective stake leaves per epoch, shared by what is leaving: 90 of 1000 here.
+ expect(stakeStillCooling(BigInt(1000), BigInt(1077), BigInt(1078), crowded)).toBe(BigInt(910))
+ })
+
+ it('counts as cooled once it is older than the history keeps', () => {
+ expect(stakeStillCooling(DELEGATED, BigInt(100), BigInt(1078), history)).toBe(BigInt(0))
+ })
+
+ it('reads the history as the sysvar lays it out, and refuses a short one', () => {
+ expect(history.get(BigInt(1077))).toEqual({
+ effective: QUIET.effective,
+ deactivating: QUIET.deactivating,
+ })
+ expect(decodeStakeHistory(new Uint8Array(4))).toBeUndefined()
+ expect(decodeStakeHistory(HISTORY.subarray(0, 40))).toBeUndefined()
+ })
+})
+
+describe('solayer positions', () => {
+ it('shows nothing for a wallet that has unstaked nothing', async () => {
+ await expect(venueOver({}).venue.positions?.(ME)).resolves.toEqual([])
+ })
+
+ it('reports a cooled account as claimable, with its whole balance', async () => {
+ await expect(venueOver({ 0: {} }).venue.positions?.(ME)).resolves.toEqual([
+ { slot: 'claimable', symbol: 'SOL', amount: '2.00166624' },
+ ])
+ })
+
+ it('reports one still cooling, with the hours until the epoch ends', async () => {
+ const { venue } = venueOver({ 3: { deactivationEpoch: BigInt(EPOCH) } }, { slotIndex: 216_000 })
+ await expect(venue.positions?.(ME)).resolves.toEqual([
+ { slot: 'cooling', symbol: 'SOL', amount: '2.00166624', readyInHours: 24 },
+ ])
+ })
+
+ it('finds an account in the last slot', async () => {
+ const { venue, rpc } = venueOver({ [STAKE_EXIT_SLOTS - 1]: {} })
+ await expect(venue.positions?.(ME)).resolves.toHaveLength(1)
+ expect(rpc.calls).not.toContain('getProgramAccounts')
+ })
+
+ it.each<[string, Partial & { owner?: string }]>([
+ ['someone else can withdraw', { withdrawer: SOMEONE_ELSE }],
+ ['a lockup holds', { lockupEpoch: BigInt(9999) }],
+ ['the stake program does not own', { owner: 'BPFLoaderUpgradeab1e11111111111111111111111' }],
+ ['is still delegated', { deactivationEpoch: NEVER_DEACTIVATED }],
+ ['never initialized', { tag: 0 }],
+ ])('leaves out an account that %s', async (_name, exit) => {
+ await expect(venueOver({ 0: exit }).venue.positions?.(ME)).resolves.toEqual([])
+ })
+
+ it.each([['getMultipleAccounts'], ['getEpochInfo']])(
+ 'throws rather than showing nothing when %s fails',
+ async (method) => {
+ await expect(
+ venueOver({ 0: {} }, { unreadable: [method] }).venue.positions?.(ME)
+ ).rejects.toThrow()
+ }
+ )
+
+ it.each<[string, unknown]>([
+ ['nonsense', { value: 'nothing' }],
+ ['too few accounts', { value: [null] }],
+ ['no reply', undefined],
+ ])('throws rather than showing nothing when a node answers with %s', async (_name, answer) => {
+ const inner = fakeRpc({ epoch: EPOCH, accounts: chainAccounts({ 0: {} }) })
+ const rpc = async (method: string, params: unknown[]): Promise =>
+ method === 'getMultipleAccounts' ? answer : inner(method, params)
+ await expect(createSolayerVenue({ rpc }).positions?.(ME)).rejects.toThrow(/could not be read/)
+ })
+
+ it('throws when the history can not be read', async () => {
+ const rpc = fakeRpc({
+ epoch: EPOCH,
+ accounts: {
+ ...chainAccounts({ 0: {} }),
+ [HISTORY_SYSVAR]: { owner: 'x', lamports: BigInt(1), data: new Uint8Array(1) },
+ },
+ })
+ await expect(createSolayerVenue({ rpc }).positions?.(ME)).rejects.toThrow()
+ })
+})
+
+describe('solayer claim', () => {
+ it('withdraws the whole balance of a cooled account to the person, who alone signs', async () => {
+ const { venue } = venueOver({ 2: {} })
+ const prepared = await venue.prepare(CLAIM, ME)
+ expect(prepared.kind).toBe('claim')
+ expect(prepared.receive).toEqual({ amount: '2.00166624', symbol: 'SOL', approximate: false })
+ const { decoded, calls } = callsOf(prepared)
+ expect(decoded.feePayer).toBe(ME)
+ expect(decoded.signerCount).toBe(1)
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.program).toBe(STAKE_PROGRAM)
+ expect(calls[0]?.accounts).toEqual([
+ stakeExitAddress(ME, 2),
+ ME,
+ CLOCK_SYSVAR,
+ HISTORY_SYSVAR,
+ ME,
+ ])
+ expect([...(calls[0]?.data.subarray(0, 4) ?? [])]).toEqual([4, 0, 0, 0])
+ expect(calls[0]?.data).toHaveLength(12)
+ expect(readU64(calls[0]!.data, 4)).toBe(HELD)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('takes every ready account at once, and leaves the cooling one', async () => {
+ const { venue } = venueOver(
+ { 0: {}, 1: { deactivationEpoch: BigInt(EPOCH) }, 4: { lamports: BigInt('5000000000') } },
+ { slotIndex: 216_000 }
+ )
+ const prepared = await venue.prepare(CLAIM, ME)
+ const { calls } = callsOf(prepared)
+ expect(calls.map((call) => call.accounts[0])).toEqual([
+ stakeExitAddress(ME, 0),
+ stakeExitAddress(ME, 4),
+ ])
+ expect(prepared.receive?.amount).toBe('7.00166624')
+ expect(prepared.notes.join(' ')).toMatch(/2\.00166624 SOL is still cooling, about 24 hours/)
+ })
+
+ it('takes as many as fit, says so, and fits a transaction', async () => {
+ const exits = Object.fromEntries(
+ Array.from({ length: MAX_CLAIMS_AT_ONCE + 2 }, (_, slot) => [slot, {}])
+ )
+ const { venue } = venueOver(exits)
+ const prepared = await venue.prepare(CLAIM, ME)
+ expect(callsOf(prepared).calls).toHaveLength(MAX_CLAIMS_AT_ONCE)
+ expect(prepared.notes.join(' ')).toMatch(/2 more .*claim again/)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ expect(Buffer.from(prepared.signing.transaction, 'base64').length).toBeLessThanOrEqual(1232)
+ expect(() => venue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('accepts sSOL as the token asked about, since that is what was unstaked', async () => {
+ await expect(
+ venueOver({ 0: {} }).venue.prepare({ ...CLAIM, tokenIn: 'sSOL' }, ME)
+ ).resolves.toMatchObject({ kind: 'claim' })
+ })
+
+ it('refuses when nothing has been unstaked', async () => {
+ await expect(venueOver({}).venue.prepare(CLAIM, ME)).rejects.toThrow(
+ /no Solayer unstake waiting.*Nothing was signed or sent/
+ )
+ })
+
+ it('refuses while it cools, saying how long is left', async () => {
+ const { venue } = venueOver({ 0: { deactivationEpoch: BigInt(EPOCH) } }, { slotIndex: 216_000 })
+ await expect(venue.prepare(CLAIM, ME)).rejects.toThrow(
+ /2\.00166624 SOL is still cooling, about 24 hours.*Nothing was signed or sent/
+ )
+ })
+
+ it('refuses an account only someone else can withdraw from', async () => {
+ const { venue } = venueOver({ 0: { withdrawer: SOMEONE_ELSE } })
+ await expect(venue.prepare(CLAIM, ME)).rejects.toThrow(
+ /another wallet.*Nothing was signed or sent/
+ )
+ })
+
+ it('refuses an account under a lockup', async () => {
+ await expect(
+ venueOver({ 0: { lockupEpoch: BigInt(9999) } }).venue.prepare(CLAIM, ME)
+ ).rejects.toThrow(/locked.*Nothing was signed or sent/)
+ })
+
+ it('refuses one put back to work, rather than call it ready', async () => {
+ await expect(
+ venueOver({ 0: { deactivationEpoch: NEVER_DEACTIVATED } }).venue.prepare(CLAIM, ME)
+ ).rejects.toThrow(/delegated again.*Nothing was signed or sent/)
+ })
+
+ it('refuses a set amount, since a claim takes what is ready', async () => {
+ await expect(venueOver({ 0: {} }).venue.prepare({ ...CLAIM, amount: '1' }, ME)).rejects.toThrow(
+ /everything that is ready/
+ )
+ })
+
+ it('refuses another token', async () => {
+ await expect(
+ venueOver({ 0: {} }).venue.prepare({ ...CLAIM, tokenIn: 'USDC' }, ME)
+ ).rejects.toThrow(/USDC/)
+ })
+
+ it('refuses what would fail on the chain, in plain words', async () => {
+ const { venue } = venueOver({ 0: {} }, { simulationError: { InstructionError: [2, 'x'] } })
+ await expect(venue.prepare(CLAIM, ME)).rejects.toThrow(
+ /would fail on the chain.*network fee.*Nothing was signed or sent/
+ )
+ })
+
+ it('refuses when the chain can not be read', async () => {
+ await expect(
+ venueOver({ 0: {} }, { unreadable: ['getMultipleAccounts'] }).venue.prepare(CLAIM, ME)
+ ).rejects.toThrow()
+ })
+})
+
+describe('the claim assertSafe', () => {
+ const { venue } = venueOver({})
+ const prepared = (): Promise => venueOver({ 0: {} }).venue.prepare(CLAIM, ME)
+
+ it('refuses SOL going to anyone but the signer', async () => {
+ const rest = rebuilt(await prepared(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 1, SOMEONE_ELSE)]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+
+ it('refuses another withdraw authority', async () => {
+ const rest = rebuilt(await prepared(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 4, SOMEONE_ELSE)]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+
+ it('refuses a stake account these unstakes could not have made', async () => {
+ const rest = rebuilt(await prepared(), ME)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -1), withAccount(rest.at(-1)!, 0, SOMEONE_ELSE)]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+
+ it('refuses the same account twice', async () => {
+ const rest = rebuilt(await prepared(), ME)
+ refuses(() => venue.assertSafe(payload(ME, [...rest, rest.at(-1)!]), ME), /more than once/)
+ })
+
+ it('refuses a withdrawal of nothing, and extra bytes', async () => {
+ const rest = rebuilt(await prepared(), ME)
+ const last = rest.at(-1)!
+ const data = Uint8Array.from(last.data ?? [])
+ data.fill(0, 4)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), { ...last, data }]), ME),
+ /isn’t what Solayer should get/
+ )
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest.slice(0, -1), withExtraData(last)]), ME),
+ /didn’t expect/
+ )
+ })
+
+ it('refuses a transfer added to it, or another program', async () => {
+ const rest = rebuilt(await prepared(), ME)
+ refuses(
+ () => venue.assertSafe(payload(ME, [...rest, transferOf(ME, SOMEONE_ELSE, BigInt(1))]), ME),
+ /SOL transfer/
+ )
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [
+ ...rest.slice(0, -1),
+ { ...rest.at(-1)!, programAddress: UNKNOWN_PROGRAM as never },
+ ]),
+ ME
+ ),
+ /should not need/
+ )
+ })
+
+ it('refuses another wallet paying', async () => {
+ const built = await prepared()
+ refuses(() => venue.assertSafe(built.signing, SOMEONE_ELSE), /network fee/)
+ refuses(() => venue.assertSafe(payload(SOMEONE_ELSE, rebuilt(built, ME)), ME), /network fee/)
+ })
+})
+
+describe('the unstake that claim has to find later', () => {
+ const unstake: ChainActionRequest = { kind: 'unstake', amount: '1', tokenIn: 'sSOL' }
+ const EPOCH_NOW = 1077
+ const BASE = {
+ poolMint: SOLAYER_LP_MINT,
+ reserveStake: 'Brh9rB6npnjM1vDXyCXtzkXVGRnsh6KHqmBz26tVACg9',
+ managerFeeAccount: 'ARs3HTD79nsaUdDKqfGhgbNMVJkXVdRs2EpHAm4LNEcq',
+ validatorList: LIST,
+ totalLamports: BigInt('82390512858986'),
+ poolTokenSupply: BigInt('69899353088216'),
+ lastUpdateEpoch: BigInt(EPOCH_NOW),
+ }
+ const list = (): Uint8Array =>
+ Uint8Array.from([
+ 2,
+ ...u32Bytes(100),
+ ...u32Bytes(1),
+ ...u64Bytes(BigInt('82389651605762')),
+ ...u64Bytes(BigInt(0)),
+ ...u64Bytes(BigInt(EPOCH_NOW)),
+ ...u64Bytes(BigInt(0)),
+ ...u32Bytes(0),
+ ...u32Bytes(0),
+ 0,
+ ...addressBytes(VOTE),
+ ])
+
+ function venueWithExits(taken: readonly number[]) {
+ const accounts: Record = {
+ [SOLAYER_STAKE_POOL]: {
+ owner: 'SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy',
+ lamports: BigInt(1),
+ data: encodeStakePool(BASE),
+ },
+ [SOLAYER_RESTAKING_POOL]: {
+ owner: SOLAYER_RESTAKING_PROGRAM,
+ lamports: BigInt(1),
+ data: new Uint8Array(74),
+ },
+ [LIST]: { owner: 'x', lamports: BigInt(1), data: list() },
+ }
+ for (const slot of taken) {
+ accounts[stakeExitAddress(ME, slot)] = {
+ owner: STAKE_PROGRAM,
+ lamports: HELD,
+ data: encodeStakeAccount({ withdrawer: ME, delegated: DELEGATED }),
+ }
+ }
+ return createSolayerVenue({ rpc: fakeRpc({ epoch: EPOCH_NOW, accounts }) })
+ }
+
+ it('makes the account in the first free slot', async () => {
+ const prepared = await venueWithExits([]).prepare(unstake, ME)
+ expect(prepared.notes[0]).toContain(stakeExitAddress(ME, 0))
+ const next = await venueWithExits([0, 1, 3]).prepare(unstake, ME)
+ expect(next.notes[0]).toContain(stakeExitAddress(ME, 2))
+ })
+
+ it('refuses a new exit when every slot is waiting, so none can fall out of sight', async () => {
+ const all = Array.from({ length: STAKE_EXIT_SLOTS }, (_, slot) => slot)
+ await expect(venueWithExits(all).prepare(unstake, ME)).rejects.toThrow(
+ /claim.*Nothing was signed or sent/
+ )
+ })
+
+ it('says the person can claim, and no longer sends them elsewhere', async () => {
+ const notes = (await venueWithExits([]).prepare(unstake, ME)).notes.join(' ')
+ expect(notes).toMatch(/"claim SOL from Solayer"/)
+ expect(notes).not.toMatch(/isn’t wired/)
+ expect(notes).not.toMatch(/command line/)
+ })
+
+ it.each([
+ ['a timestamp, which nothing could find later', 'intent-1793830450018'],
+ ['a slot past the last', `intent-stake-${STAKE_EXIT_SLOTS}`],
+ ['a slot written with a leading zero', 'intent-stake-05'],
+ ['a seed of its own', 'hidden'],
+ ])('refuses a stake account seeded with %s', async (_name, seed) => {
+ const venue = venueWithExits([])
+ const rest = rebuilt(await venue.prepare(unstake, ME), ME)
+ const swapped = createStakeAccountWithSeed(ME, seed, RENT)
+ const stakeAccount = addressWithSeed(ME, seed, STAKE_PROGRAM)
+ const fit = (instruction: Instruction, position: number): Instruction =>
+ withAccount(instruction, position, stakeAccount)
+ refuses(
+ () =>
+ venue.assertSafe(
+ payload(ME, [...rest.slice(0, -3), swapped, fit(rest.at(-2)!, 4), fit(rest.at(-1)!, 0)]),
+ ME
+ ),
+ /isn’t what Solayer should get/
+ )
+ })
+})
diff --git a/apps/dapp/lib/__tests__/staked-positions.test.ts b/apps/dapp/lib/__tests__/staked-positions.test.ts
new file mode 100644
index 00000000..38ee5a3d
--- /dev/null
+++ b/apps/dapp/lib/__tests__/staked-positions.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+
+import { associatedTokenAccount, TOKEN_PROGRAM } from '../chains/solana/assert-safe'
+import { createJitoVenue, JITOSOL_MINT } from '../chains/solana/jito'
+import { createMarinadeVenue, MSOL_MINT } from '../chains/solana/marinade'
+import { createSolayerVenue, SSOL_MINT } from '../chains/solana/solayer'
+import type { SolanaRpc } from '../chains/solana/stake-pool'
+import type { ChainVenue } from '../chains/types'
+import {
+ addressBytes,
+ fakeRpc,
+ ME,
+ SOMEONE_ELSE,
+ u64Bytes,
+ type FakeAccount,
+} from './solana-stake-fixtures'
+
+function tokenAccount(mint: string, owner: string, amount: bigint): FakeAccount {
+ return {
+ owner: TOKEN_PROGRAM,
+ lamports: BigInt(2_039_280),
+ data: Uint8Array.from([
+ ...addressBytes(mint),
+ ...addressBytes(owner),
+ ...u64Bytes(amount),
+ ...new Array(93).fill(0),
+ ]),
+ }
+}
+
+const VENUES: [string, string, string, (rpc: SolanaRpc) => ChainVenue][] = [
+ ['Jito', 'JitoSOL', JITOSOL_MINT, (rpc) => createJitoVenue({ rpc })],
+ ['Marinade', 'mSOL', MSOL_MINT, (rpc) => createMarinadeVenue({ rpc })],
+ ['Solayer', 'sSOL', SSOL_MINT, (rpc) => createSolayerVenue({ rpc })],
+]
+
+describe.each(VENUES)('%s positions', (_name, symbol, mint, build) => {
+ const own = associatedTokenAccount(ME, mint)
+ const over = (account?: FakeAccount, unreadable?: string[]): ChainVenue =>
+ build(
+ fakeRpc({
+ epoch: 1077,
+ accounts: account === undefined ? {} : { [own]: account },
+ ...(unreadable === undefined ? {} : { unreadable }),
+ })
+ )
+
+ it('counts the staking token in the wallet as staked, to the last unit', async () => {
+ await expect(
+ over(tokenAccount(mint, ME, BigInt('151268371658'))).positions?.(ME)
+ ).resolves.toEqual([{ slot: 'staked', symbol, amount: '151.268371658' }])
+ })
+
+ it('shows nothing with no token account, or an empty one', async () => {
+ await expect(over().positions?.(ME)).resolves.toEqual([])
+ await expect(over(tokenAccount(mint, ME, BigInt(0))).positions?.(ME)).resolves.toEqual([])
+ })
+
+ it('throws rather than show nothing when the chain can’t be read', async () => {
+ await expect(over(undefined, ['getMultipleAccounts']).positions?.(ME)).rejects.toThrow()
+ })
+
+ it('throws on an account that isn’t this wallet’s token account', async () => {
+ await expect(
+ over(tokenAccount(mint, SOMEONE_ELSE, BigInt(1))).positions?.(ME)
+ ).rejects.toThrow()
+ await expect(over(tokenAccount(SOMEONE_ELSE, ME, BigInt(1))).positions?.(ME)).rejects.toThrow()
+ await expect(
+ over({ ...tokenAccount(mint, ME, BigInt(1)), owner: SOMEONE_ELSE }).positions?.(ME)
+ ).rejects.toThrow()
+ })
+
+ it('throws on a wallet that isn’t an address', async () => {
+ await expect(over().positions?.('0x1234')).rejects.toThrow(/Solana wallet/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/stellar-exit-offers-pools.test.ts b/apps/dapp/lib/__tests__/stellar-exit-offers-pools.test.ts
new file mode 100644
index 00000000..f14d7df6
--- /dev/null
+++ b/apps/dapp/lib/__tests__/stellar-exit-offers-pools.test.ts
@@ -0,0 +1,248 @@
+import {
+ Account,
+ Asset,
+ BASE_FEE,
+ Memo,
+ Networks,
+ Operation,
+ Transaction,
+ TransactionBuilder,
+ getLiquidityPoolId,
+ LiquidityPoolAsset,
+} from '@stellar/stellar-sdk'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { assertSelfPoolWithdraw, buildPoolExit } from '../swap/build-pool'
+import { fetchOffer } from '../swap/offers'
+
+const ME = 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA'
+const STRANGER = 'GCYQ3NXJHGD7P36OVKII6GKVLAENQ6ZETYOBAPZTI4R6ZWUU6QLHVVUX'
+const ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
+
+function poolId(): string {
+ const usdc = new Asset('USDC', ISSUER)
+ const native = Asset.native()
+ const [a, b] = Asset.compare(native, usdc) <= 0 ? [native, usdc] : [usdc, native]
+ return Buffer.from(
+ getLiquidityPoolId('constant_product', new LiquidityPoolAsset(a, b, 30))
+ ).toString('hex')
+}
+
+function horizon(options: {
+ shares?: string
+ reserves?: { asset: string; amount: string }[]
+ id?: string
+}): typeof fetch {
+ const id = options.id ?? poolId()
+ return (async (url: string) => {
+ if (String(url).includes('/liquidity_pools/')) {
+ return new Response(
+ JSON.stringify({
+ id,
+ fee_bp: 30,
+ total_shares: '1000.0000000',
+ total_trustlines: '2',
+ reserves: options.reserves ?? [
+ { asset: 'native', amount: '5000.0000000' },
+ { asset: `USDC:${ISSUER}`, amount: '1000.0000000' },
+ ],
+ }),
+ { status: 200 }
+ )
+ }
+ return new Response(
+ JSON.stringify({
+ sequence: '1',
+ balances:
+ options.shares === undefined
+ ? []
+ : [
+ {
+ asset_type: 'liquidity_pool_shares',
+ balance: options.shares,
+ liquidity_pool_id: id,
+ },
+ ],
+ }),
+ { status: 200 }
+ )
+ }) as unknown as typeof fetch
+}
+
+describe('taking a share out of a pool', () => {
+ it('burns every share held, with floors at the share of the reserves less 1%', async () => {
+ const built = await buildPoolExit({
+ account: ME,
+ poolId: poolId(),
+ fetchImpl: horizon({ shares: '100.0000000' }),
+ })
+ expect(built.shares).toBe('100.0000000')
+ // A tenth of the pool, less 1%.
+ expect(built.floors).toEqual([
+ { code: 'XLM', amount: '495.0000000' },
+ { code: 'USDC', amount: '99.0000000' },
+ ])
+ expect(() => assertSelfPoolWithdraw(built.xdr, ME)).not.toThrow()
+
+ const tx = new Transaction(built.xdr, Networks.TESTNET)
+ expect(tx.source).toBe(ME)
+ expect(tx.operations).toHaveLength(1)
+ expect(tx.operations[0]).toMatchObject({
+ type: 'liquidityPoolWithdraw',
+ liquidityPoolId: poolId(),
+ amount: '100.0000000',
+ minAmountA: '495.0000000',
+ minAmountB: '99.0000000',
+ })
+ })
+
+ it('keeps each floor beside its own asset when Horizon lists them the other way round', async () => {
+ const built = await buildPoolExit({
+ account: ME,
+ poolId: poolId(),
+ fetchImpl: horizon({
+ shares: '100.0000000',
+ reserves: [
+ { asset: `USDC:${ISSUER}`, amount: '1000.0000000' },
+ { asset: 'native', amount: '5000.0000000' },
+ ],
+ }),
+ })
+ const op = new Transaction(built.xdr, Networks.TESTNET).operations[0]
+ // Native sorts first, so its floor is A whichever order the reserves arrived in.
+ expect(op).toMatchObject({ minAmountA: '495.0000000', minAmountB: '99.0000000' })
+ })
+
+ it('refuses an account that holds no share', async () => {
+ await expect(
+ buildPoolExit({ account: ME, poolId: poolId(), fetchImpl: horizon({}) })
+ ).rejects.toThrow(/hold no shares.*Nothing was signed or sent/)
+ })
+
+ it('refuses when the reserves read are not this pool', async () => {
+ const wrong = 'a'.repeat(64)
+ await expect(
+ buildPoolExit({
+ account: ME,
+ poolId: wrong,
+ fetchImpl: horizon({ shares: '1.0000000', id: wrong }),
+ })
+ ).rejects.toThrow(/did not match/)
+ })
+
+ it('refuses at the envelope anything but a lone withdrawal', () => {
+ const build = (op: ReturnType, source = ME): string =>
+ new TransactionBuilder(new Account(source, '1'), {
+ fee: BASE_FEE,
+ networkPassphrase: Networks.TESTNET,
+ })
+ .addOperation(op)
+ .addMemo(Memo.text('x'))
+ .setTimeout(60)
+ .build()
+ .toXDR()
+
+ expect(() =>
+ assertSelfPoolWithdraw(
+ build(Operation.payment({ destination: STRANGER, asset: Asset.native(), amount: '1' })),
+ ME
+ )
+ ).toThrow(/not a liquidity pool operation/)
+
+ const deposit = build(
+ Operation.liquidityPoolDeposit({
+ liquidityPoolId: poolId(),
+ maxAmountA: '1',
+ maxAmountB: '1',
+ minPrice: '1',
+ maxPrice: '1',
+ })
+ )
+ expect(() => assertSelfPoolWithdraw(deposit, ME)).toThrow(/not a withdrawal/)
+
+ const someoneElses = build(
+ Operation.liquidityPoolWithdraw({
+ liquidityPoolId: poolId(),
+ amount: '1',
+ minAmountA: '0',
+ minAmountB: '0',
+ }),
+ STRANGER
+ )
+ expect(() => assertSelfPoolWithdraw(someoneElses, ME)).toThrow(/refusing to sign/)
+ })
+})
+
+describe('withdrawing a resting order', () => {
+ afterEach(() => {
+ vi.doUnmock('../swap/offers')
+ vi.resetModules()
+ })
+
+ const record = {
+ id: '42',
+ seller: ME,
+ selling: { asset_type: 'native' },
+ // Not on any token list, as an order placed from another wallet app can be.
+ buying: { asset_type: 'credit_alphanum4', asset_code: 'ZZZ', asset_issuer: ISSUER },
+ amount: '5.0000000',
+ price: '2.0000000',
+ last_modified_ledger: 1,
+ }
+
+ async function route(offer: unknown): Promise<(request: Request) => Promise> {
+ vi.resetModules()
+ vi.doMock('../swap/offers', () => ({ fetchOffer: vi.fn(async () => offer) }))
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify({ sequence: '1' }), { status: 200 }))
+ )
+ return (await import('../../app/api/offers/build/route')).POST
+ }
+
+ const ask = (body: unknown): Request =>
+ new Request('http://localhost/api/offers/build', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('takes the assets from the ledger, so a token off the list can still be withdrawn', async () => {
+ const POST = await route(record)
+ const res = await POST(ask({ account: ME, amount: '0', offerId: '42' }))
+ expect(res.status).toBe(200)
+
+ const body = (await res.json()) as { xdr: string; cancelling: boolean }
+ expect(body.cancelling).toBe(true)
+ const op = new Transaction(body.xdr, Networks.TESTNET).operations[0]
+ expect(op).toMatchObject({ type: 'manageSellOffer', amount: '0.0000000', offerId: '42' })
+ }, 30_000)
+
+ it('refuses an order that belongs to someone else', async () => {
+ const POST = await route({ ...record, seller: STRANGER })
+ const res = await POST(ask({ account: ME, amount: '0', offerId: '42' }))
+ expect(res.status).toBe(403)
+ const body = (await res.json()) as { xdr?: string; detail: string }
+ expect(body.xdr).toBeUndefined()
+ expect(body.detail).toMatch(/another account.*Nothing was signed or sent/)
+ }, 30_000)
+
+ it('says when the order has already gone', async () => {
+ const POST = await route(undefined)
+ const res = await POST(ask({ account: ME, amount: '0', offerId: '42' }))
+ expect(res.status).toBe(404)
+ }, 30_000)
+})
+
+describe('reading one order', () => {
+ it('treats a 404 as gone, and anything else unreadable as a failure', async () => {
+ const answer = (status: number): typeof fetch =>
+ (async () => new Response('{}', { status })) as unknown as typeof fetch
+
+ await expect(fetchOffer('1', { fetchImpl: answer(404) })).resolves.toBeUndefined()
+ await expect(fetchOffer('1', { fetchImpl: answer(500) })).rejects.toThrow(/Horizon 500/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/stellar-exit.live.test.ts b/apps/dapp/lib/__tests__/stellar-exit.live.test.ts
new file mode 100644
index 00000000..2b3a253c
--- /dev/null
+++ b/apps/dapp/lib/__tests__/stellar-exit.live.test.ts
@@ -0,0 +1,269 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Every way out on Stellar mainnet, simulated against real positions. Nothing is
+ * signed or sent, and no key exists here.
+ *
+ * The accounts are real holders found from the pool's own events on 2026-11-05.
+ * A holder can leave, so each test takes the first still holding what it needs
+ * rather than pinning one wallet's balance.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const HORIZON = 'https://horizon.stellar.org'
+const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
+
+const BORROWERS = [
+ 'GDE2FMUG4YLSEBU2YXEQW7CSAYUWVPO6EI3JCTT4IKCHA2XZV6QJT253',
+ 'GABR5FJYD4AITJWFV2YOTEQKJPKSGMC37ESKRU6FEGNNRYV6YAPXZPWK',
+ 'GAB6HYBE6DZ2ZIDAXVQSJKUQJBEIAYHXTBLUTDQJLKI6S6WJWJ7TMGF5',
+ 'GB6Y24VSBEQUSOLJ6RAM4PCH2GXLM7MGZWUNNLC7ILRLYKQK3BPJTUTI',
+]
+const COLLATERAL_ONLY = [
+ 'GC3B2MOTNWTE4Z53ESN6WDHRTBHEZHPGXONHSAPJGF2JEGMW6X4ZM626',
+ 'GDNFNGBOYMRLSKZQ5Y2SY4DP4GSNPYMW2CFOKWO54HDBWKMSJPXBB3S7',
+ 'GDS62P4K2HCJS3L73GIKGBUVH4ZVPJY465CNLIVSBQKNNAWKVZMJGEVI',
+ 'GCJA777DKCWQYXZFVCZ6BLAEG7U5COKZFBCKIFEYZSFZG5J7URJWEFZ6',
+ 'GCVBE2GD2HPJIVZP6L5ZO45WUKEVPQKDHLGL463M3UVBT43F4HIJKZMP',
+]
+
+type Client = typeof import('../lend/blend-client')
+type Exit = typeof import('../lend/exit')
+type Position = typeof import('../lend/position')
+type Reserves = typeof import('../lend/reserves')
+type Pools = typeof import('../swap/build-pool')
+
+let client: Client
+let exit: Exit
+let position: Position
+let reserves: Reserves
+let pools: Pools
+let errors: typeof import('../lend/pool-errors')
+
+beforeAll(async () => {
+ vi.resetModules()
+ vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'mainnet')
+ client = await import('../lend/blend-client')
+ exit = await import('../lend/exit')
+ position = await import('../lend/position')
+ reserves = await import('../lend/reserves')
+ pools = await import('../swap/build-pool')
+ errors = await import('../lend/pool-errors')
+}, 60_000)
+
+afterAll(() => {
+ vi.unstubAllEnvs()
+ vi.resetModules()
+})
+
+async function firstWith(
+ accounts: string[],
+ wanted: (p: Awaited>) => boolean
+): Promise<{ account: string; held: Awaited> }> {
+ for (const account of accounts) {
+ const held = await position.readBlendPositions(account)
+ if (wanted(held)) return { account, held }
+ }
+ throw new Error('none of the known holders still qualifies: refresh the list from pool events')
+}
+
+describe.skipIf(SKIP)('the reserves this app names', () => {
+ it('are what the pool lists, in its order', async () => {
+ const list = await reserves.readReserveList()
+ expect(reserves.blendReserves().map((r) => r.id)).toEqual(list)
+ }, 60_000)
+
+ it('derive USDC and XLM to the reserves a wallet can supply', () => {
+ expect(reserves.suppliableReserve('XLM')).toBe(reserves.blendXlm())
+ expect(reserves.suppliableReserve('USDC')).toBe(reserves.blendReserveFor('USDC'))
+ })
+})
+
+describe.skipIf(SKIP)('collateral on mainnet', () => {
+ it('reads with its ticker, and comes out whole, to the wallet', async () => {
+ const { account, held } = await firstWith(
+ COLLATERAL_ONLY,
+ (p) => p.collateral.length > 0 && p.borrowed.length === 0
+ )
+ const collateral = held.collateral[0]
+ if (collateral === undefined) throw new Error('unreachable')
+ expect(collateral.symbol).toMatch(/^(XLM|USDC|EURC)$/)
+
+ const built = await client.buildBlendCollateralWithdraw({
+ account,
+ asset: collateral.assetId,
+ })
+ expect(() => client.assertSelfCollateralWithdraw(built.xdr, account)).not.toThrow()
+
+ const prepared = await client.prepareBlendWithdraw(built.xdr)
+ if (!prepared.ok) throw new Error(prepared.reason)
+ // Gone from collateral and not moved to a plain supply: it went to the wallet.
+ expect(prepared.after?.collateral[String(collateral.reserveIndex)]).toBeUndefined()
+ expect(prepared.after?.supply[String(collateral.reserveIndex)]).toBeUndefined()
+ }, 120_000)
+
+ it('is held back by the pool while a loan needs it, and the refusal says why', async () => {
+ const { account, held } = await firstWith(
+ BORROWERS,
+ (p) => p.borrowed.length > 0 && p.collateral.some((c) => c.symbol === 'XLM')
+ )
+ const xlm = held.collateral.find((c) => c.symbol === 'XLM')
+ if (xlm === undefined) throw new Error('unreachable')
+
+ const built = await client.buildBlendCollateralWithdraw({ account, asset: xlm.assetId })
+ const prepared = await client.prepareBlendWithdraw(built.xdr)
+ expect(prepared.ok).toBe(false)
+ if (!prepared.ok) {
+ expect(errors.poolErrorCode(prepared.reason)).toBe(1205)
+ expect(errors.explainExitError(prepared.reason, 'withdraw')).toMatch(/Repay the loan first/)
+ }
+ }, 120_000)
+})
+
+describe.skipIf(SKIP)('what typing "withdraw" and "repay" reaches', () => {
+ const ask = (body: unknown): Request =>
+ new Request('http://localhost/api/lend/x', { method: 'POST', body: JSON.stringify(body) })
+
+ it('"withdraw all USDC" comes back ready to sign, from the form the money is in', async () => {
+ const { POST } = await import('../../app/api/lend/withdraw/route')
+ const { account, held } = await firstWith(
+ COLLATERAL_ONLY,
+ (p) => p.borrowed.length === 0 && p.collateral.some((c) => c.symbol === 'USDC')
+ )
+ expect(held.supplied).toEqual([])
+
+ const res = await POST(ask({ account, symbol: 'usdc' }))
+ const body = (await res.json()) as {
+ xdr?: string
+ from?: string
+ remaining?: string
+ everything?: boolean
+ symbol?: string
+ error?: string
+ }
+ if (body.xdr === undefined) throw new Error(body.error)
+ expect(body).toMatchObject({
+ from: 'collateral',
+ remaining: '0',
+ everything: true,
+ symbol: 'USDC',
+ })
+ expect(() => client.assertSelfWithdraw(body.xdr as string, account)).not.toThrow()
+ }, 120_000)
+
+ it('"withdraw 25 XLM" with none there is refused in plain words', async () => {
+ const { POST } = await import('../../app/api/lend/withdraw/route')
+ const { account } = await firstWith(
+ COLLATERAL_ONLY,
+ (p) => p.supplied.length === 0 && !p.collateral.some((c) => c.symbol === 'XLM')
+ )
+ const res = await POST(ask({ account, symbol: 'XLM', amount: '250000000' }))
+ expect(res.status).toBe(400)
+ const body = (await res.json()) as { error: string }
+ expect(body.error).toMatch(/no XLM in Blend.*Nothing was signed or sent\.$/)
+ }, 120_000)
+
+ it('"repay" with an amount over the wallet says the wallet is short', async () => {
+ const { POST } = await import('../../app/api/lend/repay/route')
+ const { account, held } = await firstWith(BORROWERS, (p) => p.borrowed.length > 0)
+ const debt = held.borrowed[0]
+ if (debt === undefined) throw new Error('unreachable')
+
+ const res = await POST(ask({ account, asset: debt.assetId, amount: debt.amount }))
+ const body = (await res.json()) as { xdr?: string; error?: string }
+ // Unless this wallet has since filled up, in which case the pool would simply take it.
+ if (body.xdr === undefined) expect(body.error).toMatch(/does not hold enough/)
+ }, 120_000)
+})
+
+describe.skipIf(SKIP)('a plain supply on mainnet', () => {
+ it('builds whole-position withdrawals the pool can read', async () => {
+ // No account found holds a plain supply on this pool, since Blend's own site supplies as
+ // collateral. So this is the shape and the pool's answer, which is that there is none to take.
+ const { account } = await firstWith(COLLATERAL_ONLY, (p) => p.supplied.length === 0)
+ const built = await client.buildBlendWithdraw({ account, asset: reserves.blendXlm() })
+ expect(() => client.assertSelfWithdraw(built.xdr, account)).not.toThrow()
+
+ const prepared = await client.prepareBlendWithdraw(built.xdr)
+ expect(prepared.ok).toBe(false)
+ if (!prepared.ok) {
+ expect(errors.poolErrorCode(prepared.reason)).toBe(1217)
+ }
+ }, 120_000)
+})
+
+describe.skipIf(SKIP)('repaying on mainnet', () => {
+ it('refuses the old sentinel on the token, which is why it is gone', async () => {
+ const { account, held } = await firstWith(BORROWERS, (p) => p.borrowed.length > 0)
+ const debt = held.borrowed[0]
+ if (debt === undefined) throw new Error('unreachable')
+
+ const built = await client.buildBlendRepay({
+ account,
+ asset: debt.assetId,
+ amount: '170141183460469231731687303715884105727',
+ })
+ const prepared = await client.prepareBlendWithdraw(built.xdr)
+ expect(prepared.ok).toBe(false)
+ if (!prepared.ok) expect(prepared.reason).toMatch(/too large for an i64/)
+ }, 120_000)
+
+ it('sizes "repay all" to the wallet, and the pool accepts what it asks for', async () => {
+ // These borrowers hold little in their wallets. If one holds nothing, the plan refuses and
+ // there is nothing to simulate; one with some repays part, and the pool confirms the rest.
+ let simulated = 0
+ for (const account of BORROWERS) {
+ const held = await position.readBlendPositions(account)
+ for (const debt of held.borrowed) {
+ const spendable = await exit.readSpendable(account, debt.assetId)
+ const plan = exit.planRepayAll(BigInt(debt.amount), spendable, debt.symbol)
+ if ('refusal' in plan) {
+ expect(spendable).toBe(BigInt(0))
+ continue
+ }
+ const built = await client.buildBlendRepay({
+ account,
+ asset: debt.assetId,
+ amount: plan.amount,
+ })
+ const prepared = await client.prepareBlendWithdraw(built.xdr)
+ // One of these wallets holds a single stroop, too little for the pool to count.
+ if (!prepared.ok && errors.poolErrorCode(prepared.reason) === 1219) {
+ expect(errors.explainExitError(prepared.reason, 'repay')).toMatch(/too small/)
+ continue
+ }
+ if (!prepared.ok) throw new Error(prepared.reason)
+ const left = exit.worthOf(debt, prepared.after?.liabilities)
+ expect(BigInt(left) < BigInt(debt.amount)).toBe(true)
+ simulated += 1
+ }
+ }
+ expect(simulated).toBeGreaterThan(0)
+ }, 180_000)
+})
+
+describe.skipIf(SKIP)('a pool share on mainnet', () => {
+ it('builds a withdrawal for a real holder, paying nobody else', async () => {
+ const poolsRes = await fetch(
+ `${HORIZON}/liquidity_pools?reserves=native,USDC:${USDC_ISSUER}&limit=1`
+ )
+ const body = (await poolsRes.json()) as { _embedded: { records: { id: string }[] } }
+ const poolId = body._embedded.records[0]?.id
+ if (poolId === undefined) throw new Error('no XLM/USDC pool')
+
+ const holders = await fetch(`${HORIZON}/accounts?liquidity_pool=${poolId}&limit=1`)
+ const held = (await holders.json()) as { _embedded: { records: { id: string }[] } }
+ const account = held._embedded.records[0]?.id
+ if (account === undefined) throw new Error('no holder')
+
+ const built = await pools.buildPoolExit({ account, poolId })
+ expect(() => pools.assertSelfPoolWithdraw(built.xdr, account)).not.toThrow()
+ expect(built.floors.map((f) => f.code).sort()).toEqual(['USDC', 'XLM'])
+ await expect(
+ pools.buildPoolExit({
+ account: 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA',
+ poolId,
+ })
+ ).rejects.toThrow(/hold no shares/)
+ }, 60_000)
+})
diff --git a/apps/dapp/lib/__tests__/streamflow-cancel.test.ts b/apps/dapp/lib/__tests__/streamflow-cancel.test.ts
new file mode 100644
index 00000000..a54f9db5
--- /dev/null
+++ b/apps/dapp/lib/__tests__/streamflow-cancel.test.ts
@@ -0,0 +1,677 @@
+import {
+ getAddressEncoder,
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getTransactionDecoder,
+ type Address,
+} from '@solana/kit'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ ASSOCIATED_TOKEN_PROGRAM,
+ associatedTokenAccount,
+ decodeSolanaTransaction,
+ SYSTEM_PROGRAM,
+ TOKEN_PROGRAM,
+} from '../chains/solana/assert-safe'
+import {
+ createStreamflowVenue,
+ STREAMFLOW_PROGRAM,
+ STREAMFLOW_TREASURY,
+ type SolanaRpc,
+} from '../chains/solana/streamflow'
+import { SOLANA_TOKENS } from '../chains/solana/tokens'
+import { ChainActionRefused, type ChainActionRequest, type PreparedAction } from '../chains/types'
+
+const ACCOUNT = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+const RECIPIENT = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'
+const ATTACKER = '9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin'
+const STREAM_ID = 'CatzoSMUkTRidT5DwBxAC2pEtnwMBTpkCepHkFgZDiqb'
+const OTHER_STREAM = 'SLaYv7tCwetrFGbPCRnqpHswG5qqKino78EYpbGF7xY'
+const ESCROW = 'Brh9rB6npnjM1vDXyCXtzkXVGRnsh6KHqmBz26tVACg9'
+const USDC = SOLANA_TOKENS.find((t) => t.symbol === 'USDC')?.id as string
+const USDT = SOLANA_TOKENS.find((t) => t.symbol === 'USDT')?.id as string
+const JITOSOL = SOLANA_TOKENS.find((t) => t.symbol === 'JitoSOL')?.id as string
+const COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111'
+const BLOCKHASH = '4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn'
+const SECRET = 'a-secret-long-enough-to-sign-with-here'
+/** `sha256("global:cancel")`, first eight bytes, then the ten empty bytes the SDK sends. */
+const CANCEL_DATA = `e8dbdf29dbecdcbe${'00'.repeat(10)}`
+
+const NOW = 1_800_000_000
+const HOUR = 3600
+const CANCEL: ChainActionRequest = {
+ kind: 'cancel',
+ amount: 'all',
+ tokenIn: 'STREAM',
+ streamId: STREAM_ID,
+}
+
+interface StreamFields {
+ sender: string
+ recipient: string
+ mint: string
+ escrow: string
+ treasury: string
+ partner: string
+ deposited: bigint
+ period: bigint
+ perPeriod: bigint
+ cliff: bigint
+ cliffAmount: bigint
+ end: bigint
+ canceledAt: bigint
+ cancelableBySender: number
+ closed: number
+}
+
+/** A stream's account, at the offsets of the SDK's `streamLayout` and `constants.ts`. */
+function streamBytes(overrides: Partial = {}): Buffer {
+ const fields: StreamFields = {
+ sender: ACCOUNT,
+ recipient: RECIPIENT,
+ mint: USDC,
+ escrow: ESCROW,
+ treasury: STREAMFLOW_TREASURY,
+ partner: ACCOUNT,
+ deposited: BigInt(100_000_000),
+ period: BigInt(HOUR),
+ perPeriod: BigInt(1_000_000),
+ cliff: BigInt(NOW - 10 * HOUR),
+ cliffAmount: BigInt(0),
+ end: BigInt(NOW + 90 * HOUR),
+ canceledAt: BigInt(0),
+ cancelableBySender: 1,
+ closed: 0,
+ ...overrides,
+ }
+ const buffer = Buffer.alloc(1104)
+ const put = (at: number, value: string): void => {
+ buffer.set(getAddressEncoder().encode(value as Address), at)
+ }
+ buffer.writeBigUInt64LE(fields.canceledAt, 25)
+ buffer.writeBigUInt64LE(fields.end, 33)
+ put(49, fields.sender)
+ put(113, fields.recipient)
+ put(177, fields.mint)
+ put(209, fields.escrow)
+ put(241, fields.treasury)
+ put(325, fields.partner)
+ buffer.writeBigUInt64LE(fields.deposited, 417)
+ buffer.writeBigUInt64LE(fields.period, 425)
+ buffer.writeBigUInt64LE(fields.perPeriod, 433)
+ buffer.writeBigUInt64LE(fields.cliff, 441)
+ buffer.writeBigUInt64LE(fields.cliffAmount, 449)
+ buffer.writeUInt8(fields.cancelableBySender, 457)
+ buffer.writeUInt8(fields.closed, 671)
+ return buffer
+}
+
+function tokenAccount(amount: bigint): Buffer {
+ const buffer = Buffer.alloc(165)
+ buffer.writeBigUInt64LE(amount, 64)
+ return buffer
+}
+
+interface FakeChain {
+ stream?: Buffer | null
+ streamOwner?: string
+ before?: bigint
+ after?: bigint
+ simulationError?: unknown
+ unreadable?: readonly string[]
+ listed?: readonly { pubkey: string; data: Buffer; owner?: string }[]
+}
+
+function fakeRpc(
+ chain: FakeChain = {}
+): SolanaRpc & { seen: { method: string; params: unknown[] }[] } {
+ const seen: { method: string; params: unknown[] }[] = []
+ const rpc = async (method: string, params: unknown[]): Promise => {
+ seen.push({ method, params })
+ if (chain.unreadable?.includes(method) === true) return undefined
+ switch (method) {
+ case 'getAccountInfo': {
+ const address = params[0] as string
+ if (address === STREAM_ID) {
+ const data = chain.stream === undefined ? streamBytes() : chain.stream
+ return {
+ result: {
+ value:
+ data === null
+ ? null
+ : {
+ owner: chain.streamOwner ?? STREAMFLOW_PROGRAM,
+ data: [data.toString('base64'), 'base64'],
+ },
+ },
+ }
+ }
+ return {
+ result: {
+ value: {
+ owner: TOKEN_PROGRAM,
+ data: [tokenAccount(chain.before ?? BigInt(5_000_000)).toString('base64'), 'base64'],
+ },
+ },
+ }
+ }
+ case 'getLatestBlockhash':
+ return { result: { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 400 } } }
+ case 'simulateTransaction':
+ return {
+ result: {
+ value: {
+ err: chain.simulationError ?? null,
+ accounts: [
+ {
+ data: [
+ tokenAccount(chain.after ?? BigInt(95_000_000)).toString('base64'),
+ 'base64',
+ ],
+ },
+ ],
+ },
+ },
+ }
+ case 'getProgramAccounts':
+ return {
+ result: (chain.listed ?? []).map(({ pubkey, data, owner }) => ({
+ pubkey,
+ account: {
+ owner: owner ?? STREAMFLOW_PROGRAM,
+ data: [data.toString('base64'), 'base64'],
+ },
+ })),
+ }
+ default:
+ throw new Error(`Unexpected ${method}`)
+ }
+ }
+ return Object.assign(rpc, { seen })
+}
+
+const venueOver = (chain?: FakeChain) =>
+ createStreamflowVenue({ rpc: fakeRpc(chain), indexedRpc: fakeRpc(chain), now: () => NOW * 1000 })
+
+const venue = venueOver()
+const prepared = (chain?: FakeChain, ask: ChainActionRequest = CANCEL): Promise =>
+ venueOver(chain).prepare(ask, ACCOUNT)
+
+function transactionOf(action: PreparedAction): string {
+ if (action.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ return action.signing.transaction
+}
+
+interface Message {
+ header: {
+ numSignerAccounts: number
+ numReadonlySignerAccounts: number
+ numReadonlyNonSignerAccounts: number
+ }
+ staticAccounts: readonly Address[]
+ instructions: readonly {
+ programAddressIndex: number
+ accountIndices?: readonly number[]
+ data?: Uint8Array
+ }[]
+}
+
+function open(base64: string): Message {
+ const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64'))
+ return getCompiledTransactionMessageDecoder().decode(messageBytes) as unknown as Message
+}
+
+function close(message: Message): string {
+ const bytes = getCompiledTransactionMessageEncoder().encode(
+ message as unknown as Parameters<
+ ReturnType['encode']
+ >[0]
+ )
+ const count = message.header.numSignerAccounts
+ return Buffer.concat([
+ Buffer.from([count]),
+ Buffer.alloc(64 * count),
+ Buffer.from(bytes),
+ ]).toString('base64')
+}
+
+async function checkTampered(change: (message: Message) => Message): Promise {
+ const action = await prepared()
+ venue.assertSafe(
+ { family: 'solana', transaction: close(change(open(transactionOf(action)))) },
+ ACCOUNT,
+ action.context
+ )
+}
+
+function swapAccount(message: Message, from: string, to: string): Message {
+ expect(message.staticAccounts).toContain(from)
+ return {
+ ...message,
+ staticAccounts: message.staticAccounts.map((a) => (a === from ? (to as Address) : a)),
+ }
+}
+
+beforeEach(() => {
+ vi.stubEnv('AUTH_SECRET', SECRET)
+})
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('Streamflow cancel builds', () => {
+ it('one cancel from the person, with the accounts in the SDK’s order', async () => {
+ const action = await prepared()
+ const decoded = decodeSolanaTransaction(transactionOf(action))
+ expect(decoded.feePayer).toBe(ACCOUNT)
+ expect(decoded.signerCount).toBe(1)
+ const programs = decoded.instructions.map((ix) => decoded.staticAccounts[ix.programIndex])
+ expect(programs).toEqual([
+ COMPUTE_BUDGET,
+ COMPUTE_BUDGET,
+ ASSOCIATED_TOKEN_PROGRAM,
+ STREAMFLOW_PROGRAM,
+ ])
+ const call = decoded.instructions[3]
+ expect(Buffer.from(call?.data ?? []).toString('hex')).toBe(CANCEL_DATA)
+ expect(call?.accounts.map((i) => decoded.staticAccounts[i])).toEqual([
+ ACCOUNT,
+ ACCOUNT,
+ associatedTokenAccount(ACCOUNT, USDC),
+ RECIPIENT,
+ associatedTokenAccount(RECIPIENT, USDC),
+ STREAM_ID,
+ ESCROW,
+ STREAMFLOW_TREASURY,
+ associatedTokenAccount(STREAMFLOW_TREASURY, USDC),
+ ACCOUNT,
+ associatedTokenAccount(ACCOUNT, USDC),
+ USDC,
+ TOKEN_PROGRAM,
+ ])
+ })
+
+ it('reopens the person’s own token account first, since a closed one would block the refund', async () => {
+ const action = await prepared()
+ const decoded = decodeSolanaTransaction(transactionOf(action))
+ const open = decoded.instructions[2]
+ expect([...(open?.data ?? [])]).toEqual([1])
+ expect(open?.accounts.map((i) => decoded.staticAccounts[i])).toEqual([
+ ACCOUNT,
+ associatedTokenAccount(ACCOUNT, USDC),
+ ACCOUNT,
+ USDC,
+ SYSTEM_PROGRAM,
+ TOKEN_PROGRAM,
+ ])
+ expect(action.notes.join(' ')).toMatch(/reopens/)
+ })
+
+ it('counts the whole refund when that account was closed', async () => {
+ const rpc = fakeRpc({ after: BigInt(90_000_000) })
+ const closed: SolanaRpc = async (method, params) =>
+ method === 'getAccountInfo' && params[0] !== STREAM_ID
+ ? { result: { value: null } }
+ : rpc(method, params)
+ const action = await createStreamflowVenue({ rpc: closed, now: () => NOW * 1000 }).prepare(
+ CANCEL,
+ ACCOUNT
+ )
+ expect(action.pay.amount).toBe('90')
+ })
+
+ it('names the partner the stream recorded, whose fee this is', async () => {
+ const action = await prepared({ stream: streamBytes({ partner: STREAMFLOW_TREASURY }) })
+ const decoded = decodeSolanaTransaction(transactionOf(action))
+ const accounts = decoded.instructions[3]?.accounts.map((i) => decoded.staticAccounts[i])
+ expect(accounts?.[9]).toBe(STREAMFLOW_TREASURY)
+ expect(accounts?.[10]).toBe(associatedTokenAccount(STREAMFLOW_TREASURY, USDC))
+ expect(() => venue.assertSafe(action.signing, ACCOUNT, action.context)).not.toThrow()
+ })
+
+ it('shows what the dry run says comes back, and says who gets the rest', async () => {
+ const action = await prepared()
+ expect(action.kind).toBe('cancel')
+ expect(action.venueId).toBe('streamflow')
+ expect(action.pay).toEqual({ amount: '90', symbol: 'USDC' })
+ expect(action.receive).toEqual({ amount: '90', symbol: 'USDC', approximate: true })
+ const notes = action.notes.join(' ')
+ expect(notes).toContain(RECIPIENT)
+ expect(notes).toContain(STREAM_ID)
+ expect(notes).toMatch(/hour/)
+ expect(notes).not.toContain('—')
+ })
+
+ it('passes its own check, unsigned and signed', async () => {
+ const action = await prepared()
+ expect(() => venue.assertSafe(action.signing, ACCOUNT, action.context)).not.toThrow()
+ const signed = Buffer.from(transactionOf(action), 'base64')
+ signed.fill(7, 1, 65)
+ expect(() =>
+ venue.assertSafe(
+ { family: 'solana', transaction: signed.toString('base64') },
+ ACCOUNT,
+ action.context
+ )
+ ).not.toThrow()
+ })
+
+ it('tells the person a new stream’s id and that this app can cancel it', async () => {
+ const rpc: SolanaRpc = async (method) => {
+ if (method === 'getAccountInfo') return { result: { value: null } }
+ if (method === 'getLatestBlockhash') {
+ return { result: { value: { blockhash: BLOCKHASH, lastValidBlockHeight: 400 } } }
+ }
+ const written = streamBytes()
+ written.writeBigUInt64LE(BigInt(190_000), 305)
+ written.writeUInt32LE(160_000_000, 704)
+ return {
+ result: {
+ value: { err: null, accounts: [{ data: [written.toString('base64'), 'base64'] }] },
+ },
+ }
+ }
+ const action = await createStreamflowVenue({ rpc }).prepare(
+ { kind: 'stream', amount: '100', tokenIn: 'USDC', recipient: RECIPIENT, durationDays: '30' },
+ ACCOUNT
+ )
+ const decoded = decodeSolanaTransaction(transactionOf(action))
+ const id = decoded.staticAccounts[decoded.instructions[2]?.accounts[3] as number]
+ expect(action.notes.join(' ')).toContain(`"cancel stream ${id}"`)
+ expect(action.notes.join(' ')).not.toMatch(/can.t cancel for you/)
+ })
+})
+
+describe('Streamflow cancel refuses', () => {
+ it.each<[string, FakeChain, RegExp]>([
+ [
+ 'a stream someone else started',
+ { stream: streamBytes({ sender: ATTACKER }) },
+ /didn’t start/,
+ ],
+ [
+ 'a stream paying this wallet',
+ { stream: streamBytes({ sender: ATTACKER, recipient: ACCOUNT }) },
+ /didn’t start/,
+ ],
+ [
+ 'one that can’t be cancelled',
+ { stream: streamBytes({ cancelableBySender: 0 }) },
+ /can’t be cancelled/,
+ ],
+ ['one already closed', { stream: streamBytes({ closed: 1 }) }, /already ended/],
+ [
+ 'one already cancelled',
+ { stream: streamBytes({ canceledAt: BigInt(NOW - 1) }) },
+ /already ended/,
+ ],
+ [
+ 'one naming another treasury',
+ { stream: streamBytes({ treasury: ATTACKER }) },
+ /Streamflow’s own/,
+ ],
+ [
+ 'one in a token not streamed here',
+ { stream: streamBytes({ mint: JITOSOL }) },
+ /USDC or USDT/,
+ ],
+ ['one that isn’t there', { stream: null }, /no stream/i],
+ ['an account of another program', { streamOwner: TOKEN_PROGRAM }, /no stream/i],
+ ['an account too short to be a stream', { stream: Buffer.alloc(100) }, /no stream/i],
+ [
+ 'what would fail on the chain',
+ { simulationError: { InstructionError: [2, 'x'] } },
+ /would fail/,
+ ],
+ ['nothing coming back in the dry run', { after: BigInt(5_000_000) }, /nothing back/],
+ ['an unreadable stream', { unreadable: ['getAccountInfo'] }, /could not be read/],
+ ['no blockhash', { unreadable: ['getLatestBlockhash'] }, /could not be read/],
+ ['no dry run', { unreadable: ['simulateTransaction'] }, /could not be read/],
+ ])('%s', async (_name, chain, wanted) => {
+ const attempt = prepared(chain)
+ await expect(attempt).rejects.toThrow(wanted)
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/Nothing was signed or sent/)
+ })
+
+ it.each<[string, string | undefined]>([
+ ['no id', undefined],
+ ['an id that is not an address', 'not-a-stream'],
+ ])('%s', async (_name, streamId) => {
+ await expect(prepared({}, { ...CANCEL, streamId })).rejects.toThrow(/stream’s id/)
+ })
+
+ it('a wallet that is not a Solana address', async () => {
+ await expect(venue.prepare(CANCEL, '0x1234')).rejects.toThrow(/Solana wallet/)
+ })
+
+ it('without a secret to prove what it issued, and says where else to go', async () => {
+ vi.stubEnv('AUTH_SECRET', '')
+ await expect(prepared()).rejects.toThrow(/app\.streamflow\.finance/)
+ })
+})
+
+describe('Streamflow cancel assertSafe refuses', () => {
+ it('no proof, a forged one, or a proof for another stream or person', async () => {
+ const action = await prepared()
+ const context = action.context as Record
+ const check = (changed: Record | undefined): void =>
+ venue.assertSafe(action.signing, ACCOUNT, changed)
+ expect(() => check(undefined)).toThrow(/not what this app issued/)
+ expect(() => check({ ...context, proof: 'forged' })).toThrow(/not what this app issued/)
+ expect(() => check({ ...context, stream: OTHER_STREAM })).toThrow(/not what this app issued/)
+ expect(() => check({ ...context, recipient: ATTACKER })).toThrow(/not what this app issued/)
+ expect(() => check({ ...context, partner: ATTACKER })).toThrow(/not what this app issued/)
+ expect(() => check({ ...context, token: USDT })).toThrow(/not what this app issued/)
+ expect(() => check({ ...context, escrow: ATTACKER })).toThrow(/not what this app issued/)
+ expect(() => venue.assertSafe(action.signing, RECIPIENT, context)).toThrow(ChainActionRefused)
+ })
+
+ it('a stream’s proof used for a cancel, or a cancel’s for a stream', async () => {
+ const cancel = await prepared()
+ const proof = (cancel.context as Record