diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example
index 670e5add..92a45075 100644
--- a/apps/dapp/.env.example
+++ b/apps/dapp/.env.example
@@ -46,7 +46,13 @@ 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. Leave empty and the menu shows it as not set up.
+# 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.
@@ -195,3 +201,13 @@ NEXT_PUBLIC_SENTRY_DSN=
# a day while it stays there.
ALERT_EMAIL=
SPONSOR_ALERT_XLM=20
+
+# --- Cash-outs through Weave ---
+# Server-only key for api.paywithweave.com. It can create real orders; never prefix it NEXT_PUBLIC. Unset, cash-outs are off.
+WEAVE_SECRET_KEY=
+# Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401.
+WEAVE_WEBHOOK_SECRET=
+# Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others.
+WEAVE_CASHOUT_CURRENCIES=
+# Avalanche C-Chain RPC used to build and confirm USDC deposits.
+AVALANCHE_RPC_URL=
diff --git a/apps/dapp/app/[chain]/apps/page.tsx b/apps/dapp/app/[chain]/apps/page.tsx
index 512904ec..733c3adc 100644
--- a/apps/dapp/app/[chain]/apps/page.tsx
+++ b/apps/dapp/app/[chain]/apps/page.tsx
@@ -1,9 +1,17 @@
import { PageHeader } from '@intent/ui'
import { VenueGrid } from '../../../components/apps/venue-grid'
+import { parseChainSegment } from '@intent/config'
+
import { availableVenueIds } from '../../../lib/venues'
-export default function AppsPage(): JSX.Element {
+export default async function AppsPage({
+ params,
+}: {
+ params: Promise<{ chain: string }>
+}): Promise {
+ const route = parseChainSegment((await params).chain)
+ const racing = route?.slug === 'stellar' || route?.slug === 'arc'
// Decided here, on the server: which venues exist on this network depends
// on configuration the browser cannot see.
const availableIds = [...availableVenueIds()]
@@ -11,7 +19,11 @@ export default function AppsPage(): JSX.Element {
diff --git a/apps/dapp/app/[chain]/intents/[id]/page.tsx b/apps/dapp/app/[chain]/intents/[id]/page.tsx
index 0d39f003..55ef990b 100644
--- a/apps/dapp/app/[chain]/intents/[id]/page.tsx
+++ b/apps/dapp/app/[chain]/intents/[id]/page.tsx
@@ -1,11 +1,12 @@
'use client'
import type { IntentStatus } from '@intent/types'
-import { Badge, Button, Card, Separator, cn } from '@intent/ui'
+import { Button, Card, Separator, cn } from '@intent/ui'
import { ArrowRight, Check, ExternalLink, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
+import { chainTitle } from '../../../../lib/chain-title'
import { IntentStatusBadge } from '../../../../components/intents/intent-status-badge'
import { useCancelIntent, useIntent } from '../../../../hooks/use-intent'
import { intentTypeLabel } from '../../../../lib/intent-format'
@@ -193,9 +194,8 @@ export default function IntentDetailPage(): JSX.Element {
{/* Read from the active chain rather than hardcoded: this said
"USDC on Arc" on every intent, including Stellar ones signed
with a Stellar wallet. */}
-
- {intent.tokenIn} on {descriptor.name}{' '}
- {descriptor.networkLabel}
+
+ {intent.tokenIn} on {chainTitle(descriptor.name, descriptor.networkLabel)}
diff --git a/apps/dapp/app/[chain]/layout.tsx b/apps/dapp/app/[chain]/layout.tsx
index 6f945282..3dd77a70 100644
--- a/apps/dapp/app/[chain]/layout.tsx
+++ b/apps/dapp/app/[chain]/layout.tsx
@@ -1,6 +1,8 @@
import { chainSegments, parseChainSegment } from '@intent/config'
+import { cookies } from 'next/headers'
import { notFound } from 'next/navigation'
+import { WalletGate } from '../../components/connect/wallet-gate'
import { AppShell } from '../../components/layout/app-shell'
import { tradeCapUsd } from '../../lib/server/trade-cap'
import { ChainProvider } from '../../providers/chain-provider'
@@ -35,9 +37,17 @@ export default async function ChainLayout({
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 =
+ process.env['E2E_WALLET_GATE_BYPASS'] === '1' &&
+ (await cookies()).get('e2e-no-wallet-gate') !== undefined
+
return (
- {children}
+
+ {children}
+
)
}
diff --git a/apps/dapp/app/[chain]/loading.tsx b/apps/dapp/app/[chain]/loading.tsx
new file mode 100644
index 00000000..29ef2933
--- /dev/null
+++ b/apps/dapp/app/[chain]/loading.tsx
@@ -0,0 +1,10 @@
+import { Loader2 } from 'lucide-react'
+
+/** Shown inside the frame the moment a link is followed, while the next screen renders. */
+export default function ChainLoading(): JSX.Element {
+ return (
+
+
+
+ )
+}
diff --git a/apps/dapp/app/api/cashout/balance/route.ts b/apps/dapp/app/api/cashout/balance/route.ts
new file mode 100644
index 00000000..315fe6db
--- /dev/null
+++ b/apps/dapp/app/api/cashout/balance/route.ts
@@ -0,0 +1,45 @@
+import { NextResponse } from 'next/server'
+
+import { enforceRateLimit } from '../../../../lib/server/rate-limit'
+import { reportError } from '../../../../lib/server/report'
+import { depositChain } from '../../../../lib/weave/deposit'
+import { guardCashout } from '../../../../lib/weave/guard'
+import { isCashoutChain } from '../../../../lib/weave/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+function displayUnits(units: bigint, decimals: number): string {
+ const text = units.toString().padStart(decimals + 1, '0')
+ const whole = text.slice(0, -decimals)
+ const fraction = text.slice(-decimals).replace(/0+$/, '')
+ return fraction === '' ? whole : `${whole}.${fraction}`
+}
+
+/** How much USDC a wallet holds on a chain, for the form's "Available". */
+export async function GET(request: Request): Promise {
+ const query = new URL(request.url).searchParams
+ const chain = query.get('chain') ?? ''
+ if (!isCashoutChain(chain)) {
+ return NextResponse.json({ error: 'unknown_chain' }, { status: 400 })
+ }
+ const guard = guardCashout(request, chain)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'build')
+ if (limited !== undefined) return limited
+
+ const deposit = depositChain(chain)
+ const account = query.get('account') ?? ''
+ if (!deposit.isAccount(account)) {
+ return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
+ }
+
+ try {
+ const units = await deposit.usdcBalance(account)
+ return NextResponse.json({ balance: displayUnits(units, deposit.usdcDecimals) })
+ } catch (e) {
+ // Not answered as zero: the form would then say the wallet can't afford this.
+ reportError('cashout/balance', e, { chain })
+ return NextResponse.json({ error: 'unavailable' }, { status: 503 })
+ }
+}
diff --git a/apps/dapp/app/api/cashout/institutions/route.ts b/apps/dapp/app/api/cashout/institutions/route.ts
new file mode 100644
index 00000000..32a3402e
--- /dev/null
+++ b/apps/dapp/app/api/cashout/institutions/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from 'next/server'
+
+import { enforceRateLimit } from '../../../../lib/server/rate-limit'
+import { createWeaveClient } from '../../../../lib/weave/client'
+import { isCashoutCurrency, liveCurrencies } from '../../../../lib/weave/corridors'
+import { guardCashout } from '../../../../lib/weave/guard'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** The banks and mobile money services a currency can be paid out to. */
+export async function GET(request: Request): Promise {
+ const guard = guardCashout(request)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'build')
+ if (limited !== undefined) return limited
+
+ const currency = new URL(request.url).searchParams.get('currency') ?? ''
+ if (!isCashoutCurrency(currency) || !liveCurrencies().includes(currency)) {
+ return NextResponse.json(
+ { error: 'unknown_currency', message: 'Cash-outs do not pay out in that currency.' },
+ { status: 400 }
+ )
+ }
+
+ const result = await createWeaveClient().institutions(currency)
+ if (!result.ok) {
+ const status = result.code === 'rejected' ? 422 : 503
+ return NextResponse.json({ error: result.code, message: result.message }, { status })
+ }
+ return NextResponse.json({ institutions: result.value })
+}
diff --git a/apps/dapp/app/api/cashout/options/route.ts b/apps/dapp/app/api/cashout/options/route.ts
new file mode 100644
index 00000000..bff4d138
--- /dev/null
+++ b/apps/dapp/app/api/cashout/options/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from 'next/server'
+
+import { CORRIDORS, isCurrencyAllowed, liveCurrencies } from '../../../../lib/weave/corridors'
+import { guardCashout } from '../../../../lib/weave/guard'
+import { isCashoutChain } from '../../../../lib/weave/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** The currencies this chain can cash out to right now. */
+export async function GET(request: Request): Promise {
+ const chain = new URL(request.url).searchParams.get('chain') ?? ''
+ if (!isCashoutChain(chain)) {
+ return NextResponse.json(
+ { error: 'unknown_chain', message: 'Cash-outs are not available on this chain.' },
+ { status: 400 }
+ )
+ }
+ const guard = guardCashout(request, chain)
+ if (!guard.ok) return guard.response
+
+ return NextResponse.json({
+ currencies: liveCurrencies()
+ .filter((code) => isCurrencyAllowed(chain, code))
+ .map((code) => CORRIDORS[code]),
+ })
+}
diff --git a/apps/dapp/app/api/cashout/orders/[id]/build/route.ts b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts
new file mode 100644
index 00000000..7f86c7de
--- /dev/null
+++ b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts
@@ -0,0 +1,74 @@
+import { NextResponse } from 'next/server'
+
+import { ChainActionRefused } from '../../../../../../lib/chains/types'
+import { enforceRateLimit } from '../../../../../../lib/server/rate-limit'
+import { reportError } from '../../../../../../lib/server/report'
+import { createWeaveClient } from '../../../../../../lib/weave/client'
+import { depositChain } from '../../../../../../lib/weave/deposit'
+import { chainRefusal, guardCashout } from '../../../../../../lib/weave/guard'
+import {
+ depositExpectation,
+ getWeaveOrderRepo,
+ payRefusal,
+ safeForReport,
+ SEND_STARTED,
+ syncOrder,
+} from '../../../../../../lib/weave/orders'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * Builds the deposit for a stored order, ready for signature.
+ *
+ * The browser names an order; it does not name a destination or an amount. Both are
+ * read from the stored row, and what the chain's builder produced is read back against
+ * them before anyone is asked to sign it.
+ */
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ id: string }> }
+): Promise {
+ const guard = guardCashout(request)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'build')
+ if (limited !== undefined) return limited
+
+ const repo = await getWeaveOrderRepo()
+ const row = await repo.getForEmail((await context.params).id, guard.email)
+ if (row === undefined) return NextResponse.json({ error: 'not_found' }, { status: 404 })
+ const onThisNetwork = chainRefusal(row.chain)
+ if (onThisNetwork !== undefined) return onThisNetwork
+ if (row.sendStarted) {
+ return NextResponse.json({ error: 'send_started', message: SEND_STARTED }, { status: 409 })
+ }
+
+ const refused = payRefusal(await syncOrder(repo, row, createWeaveClient()))
+ if (refused !== undefined) {
+ return NextResponse.json(
+ { error: refused.code, message: `${refused.reason} Nothing was signed or sent.` },
+ { status: refused.status }
+ )
+ }
+
+ const deposit = depositChain(row.chain)
+ const expectation = depositExpectation(row)
+ try {
+ const signing = await deposit.build(expectation)
+ deposit.assertSafe(signing, expectation)
+ return NextResponse.json({ signing })
+ } catch (e) {
+ if (e instanceof ChainActionRefused) {
+ return NextResponse.json({ error: 'refused', message: e.message }, { status: 422 })
+ }
+ reportError('cashout/build', safeForReport(e, row), { chain: row.chain, orderId: row.id })
+ return NextResponse.json(
+ {
+ error: 'build_failed',
+ message:
+ 'The deposit could not be prepared. Nothing was signed or sent. Try again shortly.',
+ },
+ { status: 502 }
+ )
+ }
+}
diff --git a/apps/dapp/app/api/cashout/orders/[id]/route.ts b/apps/dapp/app/api/cashout/orders/[id]/route.ts
new file mode 100644
index 00000000..de62b9f0
--- /dev/null
+++ b/apps/dapp/app/api/cashout/orders/[id]/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from 'next/server'
+
+import { createWeaveClient } from '../../../../../lib/weave/client'
+import { guardCashout } from '../../../../../lib/weave/guard'
+import { getWeaveOrderRepo, syncOrder, toPublic } from '../../../../../lib/weave/orders'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * One order's state. Reconciled with Weave on every read while it is still moving,
+ * because a webhook can be missed, and falls back to the stored row when Weave cannot
+ * be reached.
+ */
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ id: string }> }
+): Promise {
+ const guard = guardCashout(request)
+ if (!guard.ok) return guard.response
+
+ const repo = await getWeaveOrderRepo()
+ // A wrong id and somebody else's id are the same answer.
+ const row = await repo.getForEmail((await context.params).id, guard.email)
+ if (row === undefined) return NextResponse.json({ error: 'not_found' }, { status: 404 })
+
+ const synced = await syncOrder(repo, row, createWeaveClient())
+ return NextResponse.json({ order: toPublic(synced.row) })
+}
diff --git a/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts b/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts
new file mode 100644
index 00000000..a60c8396
--- /dev/null
+++ b/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts
@@ -0,0 +1,215 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { ChainActionRefused, type SignedPayload } from '../../../../../../lib/chains/types'
+import { logExecution } from '../../../../../../lib/server/analytics'
+import { enforceRateLimit } from '../../../../../../lib/server/rate-limit'
+import { reportError } from '../../../../../../lib/server/report'
+import { createWeaveClient } from '../../../../../../lib/weave/client'
+import { depositChain } from '../../../../../../lib/weave/deposit'
+import { chainRefusal, guardCashout } from '../../../../../../lib/weave/guard'
+import {
+ depositExpectation,
+ getWeaveOrderRepo,
+ payRefusal,
+ safeForReport,
+ SEND_STARTED,
+ syncOrder,
+ type WeaveOrderRepo,
+ type WeaveOrderRow,
+} from '../../../../../../lib/weave/orders'
+import type { CashoutChain } from '../../../../../../lib/weave/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+// A chain's submit can poll for its confirmation: Stellar and Avalanche for about 20 to 25 seconds, Solana for about 60.
+export const maxDuration = 60
+
+const Signed = z.discriminatedUnion('family', [
+ z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }),
+ z.object({
+ family: z.literal('sui'),
+ transaction: z.string().min(1).max(262144),
+ signature: z.string().min(1).max(1024),
+ }),
+ z.object({
+ family: z.literal('near'),
+ hashes: z.array(z.string().min(1).max(128)).min(1).max(8),
+ }),
+ z.object({ family: z.literal('evm'), hash: z.string().min(1).max(128) }),
+ z.object({ family: z.literal('stellar'), signedXdr: z.string().min(1).max(262144) }),
+])
+
+const Body = z.object({ signed: Signed })
+
+function familyOf(chain: CashoutChain): SignedPayload['family'] {
+ return chain === 'avalanche' ? 'evm' : chain
+}
+
+/** These wallets send for themselves, so by the time this route hears of a transfer it has gone. */
+function walletSent(signed: SignedPayload): boolean {
+ return signed.family === 'near' || signed.family === 'evm'
+}
+
+function refuse(code: string, error: string, status: number): Response {
+ return NextResponse.json({ ok: false, code, error }, { status })
+}
+
+/**
+ * A transfer happened whatever the record says, so a database that cannot be written
+ * to never changes the answer. A hash already counted does: that transfer is not a
+ * payment of this order, however well it matches.
+ */
+async function storeHash(
+ repo: WeaveOrderRepo,
+ row: WeaveOrderRow,
+ hash: string
+): Promise<'stored' | 'reused'> {
+ try {
+ return await repo.setDepositHash(row.id, hash)
+ } catch (e) {
+ reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id })
+ return 'stored'
+ }
+}
+
+/**
+ * Sends the signed deposit for an order, or for wallets that send for themselves
+ * confirms what they sent.
+ *
+ * The order is read and asked about again: the signature took a while, and it may
+ * have ended or been paid since. What the deposit must be comes from the row, and the
+ * chain checks the bytes against it, because they passed through a browser and a
+ * wallet extension since they were built.
+ */
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ id: string }> }
+): Promise {
+ const guard = guardCashout(request)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'submit')
+ if (limited !== undefined) return limited
+
+ const parsed = Body.safeParse(await request.json().catch(() => null))
+ if (!parsed.success) {
+ return refuse(
+ 'invalid_body',
+ 'This page is out of date. Reload it and try again. Nothing was sent.',
+ 400
+ )
+ }
+ const signed = parsed.data.signed as SignedPayload
+
+ const repo = await getWeaveOrderRepo()
+ const row = await repo.getForEmail((await context.params).id, guard.email)
+ if (row === undefined)
+ return NextResponse.json({ ok: false, code: 'not_found', error: 'not_found' }, { status: 404 })
+ const onThisNetwork = chainRefusal(row.chain)
+ if (onThisNetwork !== undefined) return onThisNetwork
+
+ if (signed.family !== familyOf(row.chain)) {
+ return refuse('wrong_chain', 'This was signed for a different chain. Nothing was sent.', 400)
+ }
+
+ const sentAlready = walletSent(signed)
+ const synced = await syncOrder(repo, row, createWeaveClient())
+ // A wallet that sends for itself has already moved the money, and Weave may have
+ // seen it by now, so this only confirms and records it whatever the order's status.
+ const refused = !sentAlready || row.depositHash !== undefined ? payRefusal(synced) : undefined
+ if (refused !== undefined) {
+ // Saying nothing was sent would be untrue here: this wallet has already sent it.
+ const tail = sentAlready
+ ? 'Your wallet has already sent this transfer, so do not send it again. Check your wallet if you are unsure.'
+ : 'Nothing was sent.'
+ return refuse(refused.code, `${refused.reason} ${tail}`, refused.status)
+ }
+
+ // Taken before sending and kept unless the send is known to have failed, so a
+ // crash, a timeout or a reload cannot lead to a second deposit.
+ let claimed = false
+ try {
+ claimed = await repo.claimSend(row.id)
+ } catch (e) {
+ reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id })
+ if (!sentAlready) {
+ return refuse(
+ 'unavailable',
+ 'This deposit could not be recorded, so it was not sent. Nothing was sent. Try again in a moment.',
+ 503
+ )
+ }
+ }
+ if (!claimed && !sentAlready) {
+ return NextResponse.json(
+ { ok: false, pending: true, code: 'send_started', error: SEND_STARTED },
+ { status: 409 }
+ )
+ }
+
+ const release = async (): Promise => {
+ if (!claimed) return
+ await repo.releaseSend(row.id).catch((e: unknown) => {
+ reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id })
+ })
+ }
+
+ try {
+ const result = await depositChain(row.chain).submit(signed, depositExpectation(row))
+ const hash = (result as { hash?: unknown }).hash
+ const hashText = typeof hash === 'string' && hash !== '' ? hash : undefined
+ const pending = !result.ok && result.pending === true
+
+ const recorded =
+ hashText !== undefined && (result.ok || pending)
+ ? await storeHash(repo, row, hashText)
+ : 'stored'
+ const reused = recorded === 'reused'
+ if (reused || (!result.ok && !pending)) await release()
+
+ await logExecution({
+ kind: 'offramp',
+ account: row.account,
+ feeSponsored: false,
+ network: 'mainnet',
+ // Sent with no outcome yet is its own thing in the numbers, not an unknown failure.
+ result: reused
+ ? { ok: false, reason: 'hash_reused' }
+ : pending
+ ? { ...result, reason: 'pending' }
+ : result,
+ ...(signed.family === 'stellar' && !reused ? { signedXdr: signed.signedXdr } : {}),
+ })
+
+ if (reused) {
+ reportError('cashout/submit', new Error('deposit_hash_reused'), {
+ chain: row.chain,
+ orderId: row.id,
+ })
+ return refuse(
+ 'hash_reused',
+ "This transfer is already on record as a deposit, so it does not pay this cash-out. Do not send it again. Check your wallet's activity.",
+ 409
+ )
+ }
+
+ // The status moves only on what Weave says, so even a good answer here changes
+ // nothing but the hash, and the chain's own wording reaches the person unchanged.
+ return NextResponse.json(result, { status: result.ok ? 200 : pending ? 202 : 422 })
+ } catch (e) {
+ if (e instanceof ChainActionRefused) {
+ await release()
+ return NextResponse.json({ ok: false, error: e.message }, { status: 422 })
+ }
+ reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id })
+ return NextResponse.json(
+ {
+ ok: false,
+ pending: true,
+ error:
+ "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.",
+ },
+ { status: 502 }
+ )
+ }
+}
diff --git a/apps/dapp/app/api/cashout/orders/route.ts b/apps/dapp/app/api/cashout/orders/route.ts
new file mode 100644
index 00000000..6692fe8d
--- /dev/null
+++ b/apps/dapp/app/api/cashout/orders/route.ts
@@ -0,0 +1,312 @@
+import { NextResponse } from 'next/server'
+import { randomUUID } from 'node:crypto'
+import { z } from 'zod'
+
+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'
+import { chainRefusal, guardCashout } from '../../../../lib/weave/guard'
+import {
+ cleanMemo,
+ getWeaveOrderRepo,
+ isPastExpiry,
+ MAX_COST_PERCENT,
+ orderMismatch,
+ readUsdcAmount,
+ safeForReport,
+ toPublic,
+} from '../../../../lib/weave/orders'
+import { nextStatus } from '../../../../lib/weave/status'
+import { isCashoutChain } from '../../../../lib/weave/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const Body = z.object({
+ chain: z.string().min(1).max(20),
+ account: z.string().min(1).max(128),
+ amount: z.string().min(1).max(40),
+ currency: z.string().min(1).max(8),
+ institution: z.string().min(1).max(64),
+ accountIdentifier: z.string().regex(/^\+?[0-9A-Za-z]{6,34}$/),
+ /** Minted once per attempt by the page and reused on a retry. */
+ idempotencyKey: z.string().uuid(),
+})
+
+function refusal(error: string, message: string, status = 400): Response {
+ return NextResponse.json({ error, message }, { status })
+}
+
+/** What to tell the person about the first field that was refused. The value is never echoed. */
+function refusalFor(path: (string | number)[]): string {
+ switch (path[0]) {
+ case 'accountIdentifier':
+ return 'Check the account number and try again.'
+ case 'amount':
+ return 'Enter an amount of USDC greater than zero.'
+ case 'institution':
+ return 'Choose where the money should go.'
+ default:
+ return 'This page is out of date. Reload it and try again.'
+ }
+}
+
+/**
+ * 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)
+ if (who === undefined) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
+ if (!databaseConfigured()) return NextResponse.json({ orders: [] })
+ try {
+ const repo = await getWeaveOrderRepo()
+ const rows = await repo.listForEmail(who.email, 50)
+ return NextResponse.json({ orders: rows.map(toPublic) })
+ } catch {
+ return NextResponse.json({ orders: [] })
+ }
+}
+
+/**
+ * Creates the cash-out order and returns what the review card shows.
+ *
+ * The holder's name is found here and the order is made with that name, never with
+ * one the browser sent: it is what the bank checks the payout against. Weave's answer
+ * is checked against what was asked before it is stored or shown, and the row is
+ * stored before the response, so a deposit address is never in a person's hands
+ * without a record of it.
+ */
+export async function POST(request: Request): Promise {
+ const guard = guardCashout(request)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'build')
+ if (limited !== undefined) return limited
+
+ const parsed = Body.safeParse(await request.json().catch(() => null))
+ if (!parsed.success) {
+ return refusal('invalid_body', refusalFor(parsed.error.issues[0]?.path ?? []))
+ }
+ const input = parsed.data
+
+ const { chain } = input
+ if (!isCashoutChain(chain)) {
+ return refusal('unknown_chain', 'Cash-outs are not available on this chain.')
+ }
+ const onThisNetwork = chainRefusal(chain)
+ if (onThisNetwork !== undefined) return onThisNetwork
+
+ const deposit = depositChain(chain)
+ const amount = readUsdcAmount(input.amount, deposit.usdcDecimals)
+ if (amount === undefined) {
+ return refusal(
+ 'invalid_amount',
+ `Enter an amount of USDC greater than zero, with at most ${deposit.usdcDecimals} decimal places.`
+ )
+ }
+ if (!deposit.isAccount(input.account)) {
+ return refusal('invalid_account', 'This wallet cannot make a deposit on this chain.')
+ }
+ const { currency } = input
+ if (!isCurrencyAllowed(chain, currency)) {
+ return refusal('unknown_currency', 'This chain does not cash out to that currency.')
+ }
+
+ const repo = await getWeaveOrderRepo().catch(() => undefined)
+ const existing = await repo
+ ?.getByIdempotencyKey(input.idempotencyKey, guard.email)
+ .then((row) => ({ found: row }))
+ .catch(() => undefined)
+ // With no record to write to, no order may be made: the address could not be kept.
+ if (repo === undefined || existing === undefined) {
+ return refusal(
+ 'unavailable',
+ 'Cash-outs are not available right now. Nothing was signed or sent.',
+ 503
+ )
+ }
+
+ // A retry of the same attempt returns the order already made, not a second.
+ if (existing.found !== undefined) {
+ const row = existing.found
+ // An order that ended unpaid cannot be paid. The page starts a new attempt on this code.
+ if (row.depositHash === undefined && (row.status !== 'waiting' || isPastExpiry(row))) {
+ return refusal(
+ 'order_closed',
+ 'That cash-out ended before it was paid. Review it again to start a new one.',
+ 409
+ )
+ }
+ return NextResponse.json({ order: toPublic(row) })
+ }
+
+ // A reload forgets what the page knew, so this is asked of the record: a person who
+ // can't see their earlier deposit must not be led to pay for the same thing again.
+ const unconfirmed = await repo.hasUnconfirmedSend(input.account).catch(() => undefined)
+ if (unconfirmed !== false) {
+ return refusal(
+ unconfirmed === undefined ? 'unavailable' : 'send_unconfirmed',
+ unconfirmed === undefined
+ ? 'Cash-outs are not available right now. Nothing was sent.'
+ : 'An earlier deposit from this wallet is not confirmed yet. Wait for it to settle before starting another. Nothing new was sent.',
+ unconfirmed === undefined ? 503 : 409
+ )
+ }
+
+ 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)
+
+ const listed = await weave.institutions(currency)
+ if (!listed.ok) return fail(listed.code, listed.message)
+ const institution = listed.value.find((i) => i.code === input.institution)
+ if (institution === undefined) {
+ return refusal(
+ 'unknown_institution',
+ 'That bank or service is not available for this currency.'
+ )
+ }
+
+ // The holder is taken from this requester's own earlier order to the same account
+ // when there is one, and from Weave's lookup otherwise. Either way it is a name
+ // Weave returned, never one the browser sent.
+ let accountName = await repo
+ .findHolder(guard.email, currency, input.institution, input.accountIdentifier)
+ .catch(() => undefined)
+ if (accountName === undefined) {
+ const verified = await weave.verifyAccount({
+ institution: input.institution,
+ accountIdentifier: input.accountIdentifier,
+ currency,
+ })
+ if (!verified.ok) return fail(verified.code, verified.message)
+ accountName = verified.value.accountName
+ }
+
+ const quote = await weave.quote({ chain, currency, amount })
+ if (!quote.ok) return fail(quote.code, quote.message)
+ if (quoteCostPercent(amount, quote.value.netUsd) > MAX_COST_PERCENT) {
+ return refusal(
+ 'too_costly',
+ `This cash-out would cost more than ${MAX_COST_PERCENT}% of the amount right now, so it was not started. Nothing was signed or sent.`,
+ 422
+ )
+ }
+
+ const made = await weave.createOrder({
+ chain,
+ currency,
+ amount,
+ account: input.account,
+ institutionCode: input.institution,
+ accountIdentifier: input.accountIdentifier,
+ accountName,
+ reference: input.idempotencyKey,
+ customer: guard.email.startsWith('wallet:')
+ ? { externalId: guard.email }
+ : { email: guard.email },
+ })
+ if (!made.ok) {
+ if (made.code === 'duplicate') {
+ // Weave has an order this app has no record of, and its deposit address was
+ // never shown to anyone. The page starts a new attempt on this code.
+ return refusal('order_closed', made.message, 409)
+ }
+ if (made.code === 'unknown' || made.code === 'unexpected') {
+ reportError('cashout/orders', new Error(`order_${made.code}`), { chain, currency })
+ }
+ const status =
+ made.code === 'rejected'
+ ? 422
+ : made.code === 'unknown'
+ ? 504
+ : made.code === 'unexpected'
+ ? 502
+ : 503
+ return refusal(made.code, made.message, status)
+ }
+
+ const order = made.value
+ const mismatch = orderMismatch(order, { chain, currency, amount }, deposit)
+ if (mismatch !== undefined) {
+ reportError('cashout/orders', new Error('order_mismatch'), {
+ chain,
+ currency,
+ field: mismatch,
+ providerOrder: order.id,
+ })
+ return refusal(
+ 'unexpected_order',
+ 'The cash-out order did not match the request, so nothing was signed or sent.',
+ 502
+ )
+ }
+
+ const row = {
+ id: randomUUID(),
+ chain,
+ email: guard.email,
+ account: input.account,
+ providerOrder: order.id,
+ idempotencyKey: input.idempotencyKey,
+ currency,
+ institutionCode: input.institution,
+ institutionName: institution.name,
+ accountIdentifier: input.accountIdentifier,
+ accountName,
+ depositAddress: order.deposit.address,
+ depositMemo: cleanMemo(order.deposit.memo),
+ // What Weave needs to receive, which can be a little over what was asked. This
+ // is what the person signs, and orderMismatch has already bounded it.
+ amount: readUsdcAmount(order.deposit.amount.trim(), deposit.usdcDecimals) as string,
+ estimatedDest: String(quote.value.destAmount),
+ expiresAt: order.deposit.expiresAt,
+ status: nextStatus('waiting', order.status),
+ providerStatus: order.status,
+ }
+ try {
+ await repo.insert(row)
+ } catch (e) {
+ reportError('cashout/orders', safeForReport(e, row), {
+ chain,
+ currency,
+ providerOrder: order.id,
+ })
+ return refusal(
+ 'not_recorded',
+ 'This cash-out could not be recorded, so nothing was signed or sent. Review it again to start a new one.',
+ 503
+ )
+ }
+
+ return NextResponse.json({
+ order: toPublic({
+ ...row,
+ depositHash: undefined,
+ sendStarted: false,
+ createdAt: new Date().toISOString(),
+ }),
+ })
+}
diff --git a/apps/dapp/app/api/cashout/quote/route.ts b/apps/dapp/app/api/cashout/quote/route.ts
new file mode 100644
index 00000000..73cb5567
--- /dev/null
+++ b/apps/dapp/app/api/cashout/quote/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server'
+
+import { enforceRateLimit } from '../../../../lib/server/rate-limit'
+import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/client'
+import { isCurrencyAllowed } from '../../../../lib/weave/corridors'
+import { depositChain } from '../../../../lib/weave/deposit'
+import { guardCashout } from '../../../../lib/weave/guard'
+import { readUsdcAmount } from '../../../../lib/weave/orders'
+import { isCashoutChain } from '../../../../lib/weave/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+function refuse(error: string, message: string): Response {
+ return NextResponse.json({ error, message }, { status: 400 })
+}
+
+/** What a cash-out would pay, what it would cost, and how long it should take. */
+export async function GET(request: Request): Promise {
+ const query = new URL(request.url).searchParams
+ const chain = query.get('chain') ?? ''
+ if (!isCashoutChain(chain)) {
+ return refuse('unknown_chain', 'Cash-outs are not available on this chain.')
+ }
+ const guard = guardCashout(request, chain)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'build')
+ if (limited !== undefined) return limited
+
+ const currency = query.get('currency') ?? ''
+ if (!isCurrencyAllowed(chain, currency)) {
+ return refuse('unknown_currency', 'This chain does not cash out to that currency.')
+ }
+ const amount = readUsdcAmount(query.get('amount') ?? '', depositChain(chain).usdcDecimals)
+ if (amount === undefined) {
+ return refuse('invalid_amount', 'Enter an amount of USDC greater than zero.')
+ }
+
+ const result = await createWeaveClient().quote({ chain, currency, amount })
+ if (!result.ok) {
+ const status = result.code === 'rejected' ? 422 : 503
+ return NextResponse.json({ error: result.code, message: result.message }, { status })
+ }
+ const cost = quoteCostPercent(amount, result.value.netUsd)
+ return NextResponse.json({
+ destAmount: result.value.destAmount,
+ costPercent: Math.max(0, Math.round(cost * 100) / 100),
+ seconds: result.value.seconds,
+ })
+}
diff --git a/apps/dapp/app/api/cashout/verify/route.ts b/apps/dapp/app/api/cashout/verify/route.ts
new file mode 100644
index 00000000..65024703
--- /dev/null
+++ b/apps/dapp/app/api/cashout/verify/route.ts
@@ -0,0 +1,62 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { enforceRateLimit } from '../../../../lib/server/rate-limit'
+import { createWeaveClient } from '../../../../lib/weave/client'
+import { isCashoutCurrency, liveCurrencies } from '../../../../lib/weave/corridors'
+import { guardCashout } from '../../../../lib/weave/guard'
+import { getWeaveOrderRepo } from '../../../../lib/weave/orders'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const Body = z.object({
+ currency: z.string().min(1).max(8),
+ institution: z.string().min(1).max(64),
+ accountIdentifier: z.string().regex(/^\+?[0-9A-Za-z]{6,34}$/),
+})
+
+/**
+ * Looks up who an account belongs to, so the person can see it is the right one
+ * before any money moves. The identifier is passed through and never stored or
+ * logged here.
+ */
+export async function POST(request: Request): Promise {
+ const guard = guardCashout(request)
+ if (!guard.ok) return guard.response
+ const limited = await enforceRateLimit(request, 'resolve')
+ if (limited !== undefined) return limited
+
+ const parsed = Body.safeParse(await request.json().catch(() => null))
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'invalid_body', message: 'Check the account details and try again.' },
+ { status: 400 }
+ )
+ }
+ const { currency, institution, accountIdentifier } = parsed.data
+ if (!isCashoutCurrency(currency) || !liveCurrencies().includes(currency)) {
+ return NextResponse.json(
+ { error: 'unknown_currency', message: 'Cash-outs do not pay out in that currency.' },
+ { status: 400 }
+ )
+ }
+
+ // An account this requester has already used was looked up then, and asking
+ // again would spend a lookup to learn the same name.
+ const known = await getWeaveOrderRepo()
+ .then((repo) => repo.findHolder(guard.email, currency, institution, accountIdentifier))
+ .catch(() => undefined)
+ if (known !== undefined) return NextResponse.json({ accountName: known })
+
+ const result = await createWeaveClient().verifyAccount({
+ institution,
+ accountIdentifier,
+ currency,
+ })
+ if (!result.ok) {
+ const status = result.code === 'rejected' ? 422 : 503
+ return NextResponse.json({ error: result.code, message: result.message }, { status })
+ }
+ return NextResponse.json({ accountName: result.value.accountName })
+}
diff --git a/apps/dapp/app/api/cashout/webhook/route.ts b/apps/dapp/app/api/cashout/webhook/route.ts
new file mode 100644
index 00000000..02944fbf
--- /dev/null
+++ b/apps/dapp/app/api/cashout/webhook/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { reportError } from '../../../../lib/server/report'
+import { getWeaveOrderRepo } from '../../../../lib/weave/orders'
+import { nextStatus } from '../../../../lib/weave/status'
+import { verifyWeaveSignature } from '../../../../lib/weave/webhook'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const Event = z.object({
+ event: z.string().optional(),
+ data: z.object({ id: z.string().min(1), status: z.string().optional() }),
+})
+
+const FROM_EVENT: Readonly> = {
+ 'order.completed': 'completed',
+ 'order.failed': 'failed',
+}
+
+/**
+ * Order events from Weave.
+ *
+ * Verified over the bytes as they arrived, before anything is read from them,
+ * because re-serialising changes what the signature covers. Weave retries on any
+ * non-2xx, so an event that can never be applied gets a 200: an unknown order, a
+ * replay, or one with no order id. A record that could not be written gets a 500,
+ * since a retry can fix that.
+ */
+export async function POST(request: Request): Promise {
+ const raw = Buffer.from(await request.arrayBuffer())
+ const verified = verifyWeaveSignature(
+ raw,
+ request.headers.get('x-weave-signature'),
+ request.headers.get('x-weave-timestamp'),
+ process.env['WEAVE_WEBHOOK_SECRET'] ?? ''
+ )
+ if (!verified) return NextResponse.json({ error: 'invalid_signature' }, { status: 401 })
+
+ let body: unknown
+ try {
+ body = JSON.parse(raw.toString('utf8'))
+ } catch {
+ return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
+ }
+ const parsed = Event.safeParse(body)
+ if (!parsed.success) return NextResponse.json({ ok: true })
+
+ const { event, data } = parsed.data
+ // The status text is what the order is. The event name is only a fallback.
+ const provider = data.status ?? (event === undefined ? undefined : FROM_EVENT[event])
+ if (provider === undefined) return NextResponse.json({ ok: true })
+
+ try {
+ const repo = await getWeaveOrderRepo()
+ const row = await repo.getByProviderOrder(data.id)
+ if (row === undefined) return NextResponse.json({ ok: true })
+
+ const status = nextStatus(row.status, provider)
+ if (status !== row.status) await repo.setStatus(data.id, status, provider)
+ return NextResponse.json({ ok: true })
+ } catch (e) {
+ reportError('cashout/webhook', e, { event })
+ return NextResponse.json({ error: 'unavailable' }, { status: 500 })
+ }
+}
diff --git a/apps/dapp/app/api/chain/[chain]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts
new file mode 100644
index 00000000..bcbc7722
--- /dev/null
+++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts
@@ -0,0 +1,69 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { prepareChainAction } from '../../../../../lib/chains/prepare'
+import { chainModule } from '../../../../../lib/chains/registry'
+import { ChainActionRefused } from '../../../../../lib/chains/types'
+import { enforceRateLimit } from '../../../../../lib/server/rate-limit'
+import { reportError } from '../../../../../lib/server/report'
+import { isWalletAddress } from '../../../../../lib/wallet/address'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const Body = z.object({
+ account: z.string().min(1).max(128),
+ request: z.object({
+ kind: z.enum(['swap', 'supply', 'stake']),
+ amount: z
+ .string()
+ .regex(/^\d+(\.\d+)?$/)
+ .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(),
+ }),
+})
+
+/** Builds an action for the wallet to sign. Nothing moves until the user signs what comes back. */
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ chain: string }> }
+): Promise {
+ const limited = await enforceRateLimit(request, 'build')
+ if (limited !== undefined) return limited
+
+ const chainApi = chainModule((await params).chain)
+ if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 })
+
+ const parsed = Body.safeParse(await request.json().catch(() => undefined))
+ if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
+ if (!isWalletAddress(parsed.data.account)) {
+ return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
+ }
+
+ try {
+ const { kind, amount, tokenIn, tokenOut, venue } = parsed.data.request
+ const prepared = await prepareChainAction(
+ chainApi,
+ {
+ kind,
+ amount,
+ tokenIn,
+ ...(tokenOut !== undefined ? { tokenOut } : {}),
+ ...(venue !== undefined ? { venue } : {}),
+ },
+ parsed.data.account
+ )
+ return NextResponse.json({ prepared })
+ } catch (e) {
+ if (e instanceof ChainActionRefused) {
+ return NextResponse.json({ error: e.message }, { status: 422 })
+ }
+ reportError('chain/prepare', e, { chain: chainApi.chain })
+ return NextResponse.json(
+ { error: 'This could not be built right now. Nothing was signed or sent.' },
+ { status: 502 }
+ )
+ }
+}
diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts
new file mode 100644
index 00000000..0d58363b
--- /dev/null
+++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts
@@ -0,0 +1,98 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { chainModule } from '../../../../../lib/chains/registry'
+import { ChainActionRefused, type SigningPayload } from '../../../../../lib/chains/types'
+import { logExecution } from '../../../../../lib/server/analytics'
+import { enforceRateLimit } from '../../../../../lib/server/rate-limit'
+import { reportError } from '../../../../../lib/server/report'
+import { isWalletAddress } from '../../../../../lib/wallet/address'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const Signed = z.discriminatedUnion('family', [
+ z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }),
+ z.object({
+ family: z.literal('sui'),
+ transaction: z.string().min(1).max(262144),
+ signature: z.string().min(1).max(1024),
+ }),
+ z.object({
+ family: z.literal('near'),
+ hashes: z.array(z.string().min(1).max(128)).min(1).max(8),
+ }),
+])
+
+const Body = z.object({
+ account: z.string().min(1).max(128),
+ venueId: z.string().min(1).max(40),
+ signed: Signed,
+ context: z.record(z.string().max(2048)).optional(),
+})
+
+/**
+ * Re-checks signed bytes before sending them. Signed bytes come from the browser,
+ * so what they do is checked again here and never taken from what was prepared.
+ */
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ chain: string }> }
+): Promise {
+ const limited = await enforceRateLimit(request, 'submit')
+ if (limited !== undefined) return limited
+
+ const chainApi = chainModule((await params).chain)
+ if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 })
+
+ const parsed = Body.safeParse(await request.json().catch(() => undefined))
+ if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
+ const { account, venueId, signed, context } = parsed.data
+ if (!isWalletAddress(account)) {
+ return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
+ }
+
+ const venue = chainApi.venues.find((v) => v.id === venueId)
+ if (venue === undefined) return NextResponse.json({ error: 'unknown_venue' }, { status: 404 })
+ if (signed.family !== chainApi.chain) {
+ return NextResponse.json({ error: 'wrong_chain' }, { status: 400 })
+ }
+
+ try {
+ // A NEAR wallet has already sent its transactions, so there are no bytes left to check.
+ if (signed.family !== 'near') {
+ const payload: SigningPayload =
+ signed.family === 'solana'
+ ? { family: 'solana', transaction: signed.signedTransaction }
+ : { family: 'sui', transaction: signed.transaction }
+ venue.assertSafe(payload, account)
+ }
+
+ const input = { account, venueId, signed, ...(context !== undefined ? { context } : {}) }
+ const result = await (venue.submit ?? chainApi.submit)(input)
+ await logExecution({
+ kind: chainApi.chain,
+ account,
+ feeSponsored: false,
+ network: 'mainnet',
+ // Sent with no outcome yet is its own thing in the numbers, not an unknown failure.
+ result: !result.ok && result.pending === true ? { ...result, reason: 'pending' } : result,
+ })
+ return NextResponse.json(result, {
+ status: result.ok ? 200 : result.pending === true ? 202 : 422,
+ })
+ } catch (e) {
+ if (e instanceof ChainActionRefused) {
+ return NextResponse.json({ ok: false, error: e.message }, { status: 422 })
+ }
+ reportError('chain/submit', e, { chain: chainApi.chain })
+ return NextResponse.json(
+ {
+ ok: false,
+ error:
+ "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.",
+ },
+ { status: 502 }
+ )
+ }
+}
diff --git a/apps/dapp/app/api/prices/native/route.ts b/apps/dapp/app/api/prices/native/route.ts
new file mode 100644
index 00000000..5e6c9194
--- /dev/null
+++ b/apps/dapp/app/api/prices/native/route.ts
@@ -0,0 +1,14 @@
+import { isChainSlug } from '@intent/config'
+import { NextResponse } from 'next/server'
+
+import { fetchNativeCoinPrice } from '../../../../lib/prices/native-coin'
+
+/** The price of a chain's own coin. A null price is an ordinary answer: nothing fresh to show. */
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+export async function GET(request: Request): Promise {
+ const chain = new URL(request.url).searchParams.get('chain') ?? ''
+ if (!isChainSlug(chain)) return NextResponse.json({ error: 'unknown chain' }, { status: 400 })
+ return NextResponse.json({ price: (await fetchNativeCoinPrice(chain)) ?? null })
+}
diff --git a/apps/dapp/app/globals.css b/apps/dapp/app/globals.css
index 39fbf4ea..539f4519 100644
--- a/apps/dapp/app/globals.css
+++ b/apps/dapp/app/globals.css
@@ -1,175 +1,176 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@layer base {
- :root {
- --background: 60 20% 95.7%;
- --foreground: 60 5% 10%;
- --card: 60 20% 95.7%;
- --card-foreground: 60 5% 10%;
- --border: 60 10% 85%;
- --input: 60 10% 85%;
- --ring: 240 4.9% 83.9%;
- /* One purple for the whole app: controls, the page glow and the chat all
- use this value or a tint of it. `--glow` is kept as its own token only
- so the canvas can be tuned separately; it holds the same colour, because
- a brighter glow read as a different, bluer purple on the dark page. */
- --brand: 237 45% 45%;
- --brand-foreground: 0 0% 98%;
- --glow: 237 45% 45%;
- --accent: 189 94% 43%;
- --accent-foreground: 0 0% 98%;
- --muted: 60 10% 90%;
- --muted-foreground: 60 5% 40%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 0 0% 98%;
- --success: 142 76% 36%;
- --warning: 38 92% 50%;
- --surface-base: 60 20% 95.7%;
- --surface-elevated: 60 20% 95.7%;
- --surface-card: 60 20% 95.7%;
- --radius: 0.4rem;
- /* Chat bubbles: the user's in a light brand purple, the agents' in white. */
- --bubble-me: 237 45% 91%;
- --bubble-me-foreground: 237 45% 30%;
- --bubble-agent: 0 0% 100%;
- --bubble-agent-foreground: 60 5% 10%;
- --bubble-agent-border: 237 30% 92%;
- }
-
- /*
- * Dark: black, grey and ash.
- *
- * Built as a neutral ladder rather than an inverted light theme. The
- * background is near-black and each surface above it is a step lighter, so
- * depth comes from value alone and nothing has to be tinted to read as
- * raised.
- *
- * The brand purple is deliberately unchanged. It is the only saturated
- * colour in either theme, which is what makes a button unmistakably a
- * button — recolouring it per theme would cost that.
- *
- * The one exception to "value alone" is the page canvas: see `.app-canvas`
- * below, which lays a brand-tinted glow and film grain under the content in
- * both themes. Surfaces above it stay neutral, so they still read as raised.
- *
- * Only tokens are redefined here. Every component reads these, so a
- * component that hardcodes a colour is a bug this file cannot fix.
- */
- .dark {
- --background: 0 0% 6%;
- --foreground: 0 0% 93%;
-
- /* One step above the page, so a card reads as raised without a border. */
- --card: 0 0% 9%;
- --card-foreground: 0 0% 93%;
-
- /* Low contrast against the card: a visible edge, not a drawn line. */
- --border: 0 0% 18%;
- --input: 0 0% 18%;
- --ring: 0 0% 32%;
-
- /* Unchanged from light. The one colour that carries meaning. */
- --brand: 237 45% 45%;
- --brand-foreground: 0 0% 98%;
- --glow: 237 45% 45%;
- --accent: 189 94% 43%;
- --accent-foreground: 0 0% 98%;
-
- --muted: 0 0% 14%;
- /* Ash. Around 4.5:1 on the page background — readable as secondary text
- rather than as something disabled. */
- --muted-foreground: 0 0% 64%;
-
- /* Lighter than the light-theme red, which is too dark to read here. */
- --destructive: 0 72% 51%;
- --destructive-foreground: 0 0% 98%;
- --success: 142 69% 45%;
- --warning: 38 92% 55%;
-
- --surface-base: 0 0% 6%;
- --surface-elevated: 0 0% 11%;
- --surface-card: 0 0% 9%;
-
- /* Agents go purple-black rather than grey, so a reply still reads as the
- brand's side of the thread. */
- --bubble-me: 237 45% 82%;
- --bubble-me-foreground: 237 45% 22%;
- --bubble-agent: 238 35% 29%; /* #303263 */
- --bubble-agent-foreground: 237 45% 96%;
- --bubble-agent-border: 238 30% 37%;
- }
-}
-
-@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground font-sans;
- }
- ::selection {
- @apply bg-brand/20 text-brand;
- }
-}
-
-/*
- * Page canvas: a brand glow with film grain.
- *
- * Applied to the column that holds the header and the scrolling page. The
- * column itself never scrolls, so the glow stays put and the content moves
- * over it. Cards and inputs are opaque, so the tint shows only in the gaps
- * between them.
- *
- * The grain is an SVG turbulence tile in a pseudo-element rather than a second
- * background layer, so its strength is one `opacity` value. `isolation` keeps
- * the negative z-index inside this element; without it the grain would drop
- * behind the page.
- *
- * One strength for both themes. Light used to run gentler on the theory that
- * the same alpha reads louder on off-white; in practice it read as a faint
- * wash next to the dark canvas, and the brief is that the glow and grain be
- * just as present in light. The grain darkens a light page and brightens a
- * dark one, which is the only difference left.
- */
-.app-canvas {
- position: relative;
- isolation: isolate;
- background-color: hsl(var(--background));
- background-image:
- radial-gradient(
- ellipse 90% 70% at 50% -15%,
- hsl(var(--glow) / 0.6) 0%,
- hsl(var(--glow) / 0.28) 35%,
- hsl(var(--glow) / 0) 72%
- ),
- radial-gradient(ellipse 60% 40% at 50% 0%, hsl(var(--glow) / 0.25), transparent 60%);
-}
-
-.app-canvas::before {
- content: '';
- position: absolute;
- inset: 0;
- z-index: -1;
- pointer-events: none;
- opacity: 0.11;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
- background-size: 240px 240px;
-}
-
-/* Scrolls without drawing a bar, for panes whose content already shows where it ends. */
-.scrollbar-none {
- scrollbar-width: none;
-}
-.scrollbar-none::-webkit-scrollbar {
- display: none;
-}
-
-/* RainbowKit connect button: normal weight to match the flat, no-bold rule. */
-[data-rk] button {
- font-weight: 400 !important;
-}
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 60 20% 95.7%;
+ --foreground: 60 5% 10%;
+ --card: 60 20% 95.7%;
+ --card-foreground: 60 5% 10%;
+ --border: 60 10% 85%;
+ --input: 60 10% 85%;
+ --ring: 240 4.9% 83.9%;
+ /* One purple for the whole app: controls, the page glow and the chat all
+ use this value or a tint of it. `--glow` is kept as its own token only
+ so the canvas can be tuned separately; it holds the same colour, because
+ a brighter glow read as a different, bluer purple on the dark page. */
+ --brand: 237 45% 45%;
+ --brand-foreground: 0 0% 98%;
+ --glow: 237 45% 45%;
+ --accent: 189 94% 43%;
+ --accent-foreground: 0 0% 98%;
+ --muted: 60 10% 90%;
+ --muted-foreground: 60 5% 40%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --success: 142 76% 36%;
+ --warning: 38 92% 50%;
+ --surface-base: 60 20% 95.7%;
+ --surface-elevated: 60 20% 95.7%;
+ --surface-card: 60 20% 95.7%;
+ --radius: 0.4rem;
+ /* Chat bubbles: the user's in a light brand purple, the agents' in white. */
+ --bubble-me: 237 45% 91%;
+ --bubble-me-foreground: 237 45% 30%;
+ --bubble-agent: 0 0% 100%;
+ --bubble-agent-foreground: 60 5% 10%;
+ --bubble-agent-border: 237 30% 92%;
+ }
+
+ /*
+ * Dark: black, grey and ash.
+ *
+ * Built as a neutral ladder rather than an inverted light theme. The
+ * background is near-black and each surface above it is a step lighter, so
+ * depth comes from value alone and nothing has to be tinted to read as
+ * raised.
+ *
+ * The brand purple is deliberately unchanged. It is the only saturated
+ * colour in either theme, which is what makes a button unmistakably a
+ * button — recolouring it per theme would cost that.
+ *
+ * The one exception to "value alone" is the page canvas: see `.app-canvas`
+ * below, which lays a brand-tinted glow and film grain under the content in
+ * both themes. Surfaces above it stay neutral, so they still read as raised.
+ *
+ * Only tokens are redefined here. Every component reads these, so a
+ * component that hardcodes a colour is a bug this file cannot fix.
+ */
+ .dark {
+ --background: 0 0% 6%;
+ --foreground: 0 0% 93%;
+
+ /* One step above the page, so a card reads as raised without a border. */
+ --card: 0 0% 9%;
+ --card-foreground: 0 0% 93%;
+
+ /* Low contrast against the card: a visible edge, not a drawn line. */
+ --border: 0 0% 18%;
+ --input: 0 0% 18%;
+ --ring: 0 0% 32%;
+
+ /* Unchanged from light. The one colour that carries meaning. */
+ --brand: 237 45% 45%;
+ --brand-foreground: 0 0% 98%;
+ --glow: 237 45% 45%;
+ --accent: 189 94% 43%;
+ --accent-foreground: 0 0% 98%;
+
+ --muted: 0 0% 14%;
+ /* Ash. Around 4.5:1 on the page background — readable as secondary text
+ rather than as something disabled. */
+ --muted-foreground: 0 0% 64%;
+
+ /* Lighter than the light-theme red, which is too dark to read here. */
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 0 0% 98%;
+ --success: 142 69% 45%;
+ --warning: 38 92% 55%;
+
+ --surface-base: 0 0% 6%;
+ --surface-elevated: 0 0% 11%;
+ --surface-card: 0 0% 9%;
+
+ /* Agents go purple-black rather than grey, so a reply still reads as the
+ brand's side of the thread. */
+ --bubble-me: 237 45% 82%;
+ --bubble-me-foreground: 237 45% 22%;
+ --bubble-agent: 238 35% 29%; /* #303263 */
+ --bubble-agent-foreground: 237 45% 96%;
+ --bubble-agent-border: 238 30% 37%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground font-sans;
+ }
+ ::selection {
+ @apply bg-brand/20 text-brand;
+ }
+}
+
+/*
+ * Page canvas: a brand glow with film grain.
+ *
+ * Applied to the column that holds the header and the scrolling page. The
+ * column itself never scrolls, so the glow stays put and the content moves
+ * over it. Cards and inputs are opaque, so the tint shows only in the gaps
+ * between them.
+ *
+ * The grain is an SVG turbulence tile in a pseudo-element rather than a second
+ * background layer, so its strength is one `opacity` value. `isolation` keeps
+ * the negative z-index inside this element; without it the grain would drop
+ * behind the page.
+ *
+ * One strength for both themes. Light used to run gentler on the theory that
+ * the same alpha reads louder on off-white; in practice it read as a faint
+ * wash next to the dark canvas, and the brief is that the glow and grain be
+ * just as present in light. The grain darkens a light page and brightens a
+ * dark one, which is the only difference left.
+ */
+.app-canvas {
+ position: relative;
+ isolation: isolate;
+ background-color: hsl(var(--background));
+ background-image:
+ radial-gradient(
+ ellipse 90% 70% at 50% -15%,
+ hsl(var(--glow) / 0.6) 0%,
+ hsl(var(--glow) / 0.28) 35%,
+ hsl(var(--glow) / 0) 72%
+ ),
+ radial-gradient(ellipse 60% 40% at 50% 0%, hsl(var(--glow) / 0.25), transparent 60%);
+}
+
+.app-canvas::before,
+.showcase-panel::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ opacity: 0.11;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
+ background-size: 240px 240px;
+}
+
+/* Scrolls without drawing a bar, for panes whose content already shows where it ends. */
+.scrollbar-none {
+ scrollbar-width: none;
+}
+.scrollbar-none::-webkit-scrollbar {
+ display: none;
+}
+
+/* RainbowKit connect button: normal weight to match the flat, no-bold rule. */
+[data-rk] button {
+ font-weight: 400 !important;
+}
/* Admin charts: five categorical slots, validated per theme. Slot 1 is the brand violet. */
:root {
@@ -187,3 +188,13 @@
--series-4: #c98500;
--series-5: #d55181;
}
+
+/* The connect page's slide timer. Its end is what moves to the next slide. */
+@keyframes showcase-progress {
+ from {
+ transform: scaleX(0);
+ }
+ to {
+ transform: scaleX(1);
+ }
+}
diff --git a/apps/dapp/app/layout.tsx b/apps/dapp/app/layout.tsx
index 0a6378a8..f797b320 100644
--- a/apps/dapp/app/layout.tsx
+++ b/apps/dapp/app/layout.tsx
@@ -1,42 +1,44 @@
-import type { Metadata } from 'next'
-import { Inter, JetBrains_Mono, Playfair_Display, Cormorant } from 'next/font/google'
-
-import { RootProviders } from '../providers/root'
-
-import './globals.css'
-
-const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' })
-const playfair = Playfair_Display({
- subsets: ['latin'],
- style: ['normal', 'italic'],
- variable: '--font-playfair',
- display: 'swap',
-})
-const cormorant = Cormorant({
- subsets: ['latin'],
- style: ['normal', 'italic'],
- variable: '--font-cormorant',
- display: 'swap',
-})
-const geistMono = JetBrains_Mono({
- subsets: ['latin'],
- variable: '--font-geist-mono',
- display: 'swap',
-})
-
-export const metadata: Metadata = {
- title: { default: 'Intent', template: '%s · Intent' },
- description: 'Submit intents. Watch agents compete. Settle on-chain.',
-}
-
-export default function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
- {children}
-
-
- )
-}
+import type { Metadata } from 'next'
+import { Inter, JetBrains_Mono, Playfair_Display, Cormorant } from 'next/font/google'
+
+import { RootProviders } from '../providers/root'
+
+import './globals.css'
+
+const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' })
+const playfair = Playfair_Display({
+ subsets: ['latin'],
+ style: ['normal', 'italic'],
+ variable: '--font-playfair',
+ display: 'swap',
+})
+const cormorant = Cormorant({
+ subsets: ['latin'],
+ style: ['normal', 'italic'],
+ variable: '--font-cormorant',
+ display: 'swap',
+})
+const geistMono = JetBrains_Mono({
+ subsets: ['latin'],
+ variable: '--font-geist-mono',
+ display: 'swap',
+})
+
+export const metadata: Metadata = {
+ title: { default: 'Intent', template: '%s · Intent' },
+ description: 'Submit intents. Watch agents compete. Settle on-chain.',
+}
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {/* Browser extensions such as ColorZilla add attributes to before React loads. */}
+
+ {children}
+
+
+ )
+}
diff --git a/apps/dapp/app/page.tsx b/apps/dapp/app/page.tsx
new file mode 100644
index 00000000..b0319c6b
--- /dev/null
+++ b/apps/dapp/app/page.tsx
@@ -0,0 +1,12 @@
+import { Suspense } from 'react'
+
+import { ConnectHome } from '../components/connect/connect-home'
+
+/** The app's front door: connect a wallet, and the chain it is on goes into the address. */
+export default function HomePage(): JSX.Element {
+ return (
+ }>
+
+
+ )
+}
diff --git a/apps/dapp/components/apps/venue-card.tsx b/apps/dapp/components/apps/venue-card.tsx
index 1400dd5e..099df6ed 100644
--- a/apps/dapp/components/apps/venue-card.tsx
+++ b/apps/dapp/components/apps/venue-card.tsx
@@ -6,7 +6,13 @@ import { ArrowUpRight, BadgeCheck } from 'lucide-react'
import Image from 'next/image'
import { useEffect, useRef, useState } from 'react'
+import { chainMarkFor } from '../../lib/chain-mark'
+import { venueDetail } from '../../lib/venues'
+
export const categoryLabel: Record = {
+ savings: 'Savings',
+ staking: 'Staking',
+ payments: 'Payments',
swap: 'Swap',
aggregator: 'Aggregator',
orderbook: 'Order book',
@@ -19,13 +25,6 @@ export const categoryLabel: Record = {
infrastructure: 'Network',
}
-function chainSlug(chain: string): string {
- return chain
- .toLowerCase()
- .replace(/\s*chain$/, '')
- .trim()
-}
-
/**
* The verified mark, for a venue intents can actually be signed through.
*
@@ -153,7 +152,7 @@ export function VenueCard({
target="_blank"
rel="noopener noreferrer"
// What the app can actually do here, for anyone who wants the detail.
- title={venue.capability ?? venue.bestFor}
+ title={venueDetail(venue, notHere)}
className={cn(
'bg-bubble-agent text-bubble-agent-foreground border-bubble-agent-border group relative isolate flex flex-col justify-between gap-4 overflow-hidden rounded-3xl border p-4 transition-all duration-200 sm:aspect-square',
live
@@ -170,27 +169,26 @@ export function VenueCard({
{showChains ? (
- {venue.chains
- .slice(0, 3)
- .map((chain) =>
- chainSlug(chain) === 'arc' ? (
-
- ) : (
-
- )
- )}
+ {venue.chains.slice(0, 3).map((chain) => {
+ const badge = chainMarkFor(chain)
+ return badge.kind === 'mark' ? (
+
+ ) : (
+
+ )
+ })}
) : null}
@@ -226,7 +224,13 @@ export function VenueCard({
-
{integration === 'quotes' ? 'Prices live' : 'Agents trade here'}
+
+ {integration === 'quotes'
+ ? 'Prices live'
+ : venue.family === 'stellar'
+ ? 'Agents trade here'
+ : 'Works from chat'}
+
>
) : (
<>
diff --git a/apps/dapp/components/apps/venue-grid.tsx b/apps/dapp/components/apps/venue-grid.tsx
index afe61f62..85f0b126 100644
--- a/apps/dapp/components/apps/venue-grid.tsx
+++ b/apps/dapp/components/apps/venue-grid.tsx
@@ -6,7 +6,7 @@ import { Search, X } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useChain } from '../../providers/chain-provider'
-import { notOnNetworkLabel, venues } from '../../lib/venues'
+import { isListedOn, notOnNetworkLabel, venueDetail, venues } from '../../lib/venues'
import { categoryLabel, VenueCard, VenueLogo, VerifiedTick } from './venue-card'
/** Integrated first, then quoted, then merely listed. */
@@ -42,7 +42,7 @@ export function VenueGrid({
const rows = useMemo
(
() =>
venues
- .filter((v) => v.family === descriptor.family)
+ .filter((v) => v.family === descriptor.family && isListedOn(v))
// Venues the app can actually use come first. Alphabetical order put
// Aquarius — then only priced, never signed — above Soroswap, which
// intents routed through, so the most useful thing was buried.
@@ -66,13 +66,13 @@ export function VenueGrid({
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
- return rows.filter(({ venue: v }) => {
+ return rows.filter(({ venue: v, notHere }) => {
if (category !== 'all' && v.category !== category) return false
// Capability is searchable too: someone looking for "treasuries" or
// "lending" is describing what they want done, not naming a venue.
return (
q === '' ||
- [v.name, categoryLabel[v.category], v.bestFor, v.capability ?? ''].some((f) =>
+ [v.name, categoryLabel[v.category], v.bestFor, venueDetail(v, notHere)].some((f) =>
f.toLowerCase().includes(q)
)
)
@@ -104,7 +104,9 @@ export function VenueGrid({
{liveRows.length} live venues
- Agents compete across all of them on every intent. You never pick one yourself.
+ {descriptor.family === 'stellar'
+ ? 'Agents compete across all of them on every intent. You never pick one yourself.'
+ : 'Ask in chat and Intent uses the right one, or name the one you want.'}
{rows.length > liveRows.length ? (
diff --git a/apps/dapp/components/connect/connect-dialog.tsx b/apps/dapp/components/connect/connect-dialog.tsx
new file mode 100644
index 00000000..48daf659
--- /dev/null
+++ b/apps/dapp/components/connect/connect-dialog.tsx
@@ -0,0 +1,272 @@
+'use client'
+
+import { CHAIN_DESCRIPTORS, CONNECT_CHAINS } from '@intent/config'
+import type { ChainSlug } from '@intent/types'
+import { Button, ChainMark, cn } from '@intent/ui'
+import { useQuery } from '@tanstack/react-query'
+import { ArrowLeft, ArrowUpRight, Loader2, Wallet, X } from 'lucide-react'
+import Image from 'next/image'
+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'
+
+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'
+
+/**
+ * Connecting, in two steps: pick a chain, then pick a wallet on that chain. It
+ * looks the same on every chain, because the wallet list is ours and each chain's
+ * library only supplies the data and the connect call.
+ *
+ * A chain that already has a wallet connected skips the list, since there is
+ * nothing left to ask.
+ */
+export function ConnectSteps({
+ initialChain,
+ onConnected,
+ onBack,
+ trailing,
+}: {
+ /** Opens directly on this chain's wallets, as the chain switcher does. */
+ initialChain?: ChainSlug | undefined
+ onConnected: (slug: ChainSlug) => void
+ /** Where the back arrow goes from the chain step. Without it, that step has no arrow. */
+ onBack?: (() => void) | undefined
+ /** Beside the title, such as a dialog's close button. */
+ trailing?: ReactNode
+}): JSX.Element {
+ const [chain, setChain] = useState(initialChain)
+ const root = useRef(null)
+
+ useEffect(() => {
+ root.current?.querySelector('button:not([aria-label="Close"]), a')?.focus()
+ }, [chain])
+
+ function pick(slug: ChainSlug): void {
+ if (connectorFor(slug).isConnected()) onConnected(slug)
+ else setChain(slug)
+ }
+
+ const title =
+ chain === undefined ? 'Choose a chain' : `Connect to ${CHAIN_DESCRIPTORS[chain].name}`
+ const back = chain !== undefined ? () => setChain(undefined) : onBack
+
+ return (
+
+
+ {back !== undefined ? (
+
+
+
+ ) : null}
+
{title}
+ {trailing}
+
+
+ {chain === undefined ? (
+
+ ) : (
+
onConnected(chain)} />
+ )}
+
+ )
+}
+
+/** The steps over the app, for moving to a chain without leaving the screen. */
+export function ConnectDialog({
+ initialChain,
+ onConnected,
+ onClose,
+}: {
+ initialChain?: ChainSlug | undefined
+ onConnected: (slug: ChainSlug) => void
+ onClose: () => void
+}): JSX.Element {
+ useEffect(() => {
+ function onKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ return (
+
+ )
+}
+
+function ChainGrid({ onPick }: { onPick: (slug: ChainSlug) => void }): JSX.Element {
+ return (
+
+ {CONNECT_CHAINS.map((slug) => (
+ onPick(slug)} className={rowClass}>
+
+ {CHAIN_DESCRIPTORS[slug].name}
+
+ ))}
+
+ )
+}
+
+function WalletList({
+ chain,
+ onConnected,
+}: {
+ chain: ChainSlug
+ onConnected: () => void
+}): JSX.Element {
+ const connector = connectorFor(chain)
+ const name = CHAIN_DESCRIPTORS[chain].name
+ const [connecting, setConnecting] = useState(undefined)
+ const [error, setError] = useState(undefined)
+
+ const wallets = useQuery({
+ queryKey: ['connect-wallets', chain],
+ queryFn: () => connector.listWallets(),
+ staleTime: 0,
+ gcTime: 0,
+ })
+
+ async function connectTo(wallet: WalletOption): Promise {
+ setConnecting(wallet.id)
+ setError(undefined)
+ try {
+ await connector.connect(wallet.id)
+ onConnected()
+ } catch (e) {
+ const message = walletErrorMessage(e)
+ setError(isUserRejection(message) ? undefined : message)
+ } finally {
+ setConnecting(undefined)
+ }
+ }
+
+ if (wallets.isPending) {
+ return (
+
+
+ Finding {name} wallets
+
+ )
+ }
+ if (wallets.isError) {
+ return (
+
+ Could not load {name} wallets. Check your connection and try again.
+ void wallets.refetch()}>
+ Try again
+
+
+ )
+ }
+ if (wallets.data.length === 0) {
+ return No {name} wallets are available in this browser. Install one, then reload.
+ }
+
+ return (
+ <>
+
+ {wallets.data.map((wallet) => (
+
+ {wallet.detected ? (
+ void connectTo(wallet)}
+ className={cn(rowClass, 'disabled:cursor-wait disabled:opacity-60')}
+ >
+
+ {wallet.name}
+ {connecting === wallet.id ? (
+
+
+ Confirm in wallet
+
+ ) : null}
+
+ ) : (
+
+
+ {wallet.name}
+
+ Install
+
+
+
+ )}
+
+ ))}
+
+ {error !== undefined ? (
+
+ {error}
+
+ ) : null}
+ >
+ )
+}
+
+function WalletIcon({ wallet }: { wallet: WalletOption }): JSX.Element {
+ // Icons are hosted by each wallet, so any of them can fail to load.
+ const [failed, setFailed] = useState(false)
+ return wallet.icon !== undefined && !failed ? (
+ setFailed(true)}
+ className="h-7 w-7 shrink-0 rounded-md object-contain"
+ />
+ ) : (
+
+
+
+ )
+}
+
+function Note({ children }: { children: ReactNode }): JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/dapp/components/connect/connect-home.tsx b/apps/dapp/components/connect/connect-home.tsx
new file mode 100644
index 00000000..5951f2c9
--- /dev/null
+++ b/apps/dapp/components/connect/connect-home.tsx
@@ -0,0 +1,54 @@
+'use client'
+
+import {
+ chainSegment,
+ defaultNetwork,
+ homeSegment,
+ isMultiNetwork,
+ parseChainSegment,
+ setClientNetwork,
+} from '@intent/config'
+import type { ChainSlug } from '@intent/types'
+import dynamic from 'next/dynamic'
+import { useRouter, useSearchParams } from 'next/navigation'
+
+import { installNetworkFetch } from '../../lib/api/network-fetch'
+import { rememberChain } from '../../lib/wallet/chain-cookie'
+import { screenAfterConnect } from '../../lib/wallet/chain-path'
+
+/** Browser only: the page reads the window to decide whether the slides fit. */
+const ConnectPage = dynamic(() => import('./connect-page').then((m) => m.ConnectPage), {
+ ssr: false,
+ loading: () =>
,
+})
+
+/**
+ * The bare address of the app, before a chain has been picked.
+ *
+ * There is no chain in the URL yet, so nothing has told the app which Stellar
+ * network a Stellar wallet would connect to. It is the one the app opens on,
+ * which keeps the wallet's session under the network it then lands on.
+ */
+export function ConnectHome(): JSX.Element {
+ const router = useRouter()
+ const next = useSearchParams().get('next')
+
+ const stellarHome = homeSegment()
+ const network = parseChainSegment(stellarHome)?.network ?? defaultNetwork()
+ if (typeof window !== 'undefined') {
+ setClientNetwork(network)
+ if (isMultiNetwork()) installNetworkFetch(network)
+ }
+
+ function onConnected(slug: ChainSlug): void {
+ const segment = slug === 'stellar' ? stellarHome : chainSegment(slug)
+ rememberChain(segment)
+ const href = `/${segment}${screenAfterConnect(next)}`
+ // A full load into Stellar when several networks are served: the network
+ // decides what every cached call means, and none of it should carry across.
+ if (slug === 'stellar' && isMultiNetwork()) window.location.assign(href)
+ else router.push(href)
+ }
+
+ return
+}
diff --git a/apps/dapp/components/connect/connect-page.tsx b/apps/dapp/components/connect/connect-page.tsx
new file mode 100644
index 00000000..5112136a
--- /dev/null
+++ b/apps/dapp/components/connect/connect-page.tsx
@@ -0,0 +1,112 @@
+'use client'
+
+import { CHAIN_DESCRIPTORS, CONNECT_CHAINS } from '@intent/config'
+import type { ChainSlug } from '@intent/types'
+import { Button, ChainMark, cn } from '@intent/ui'
+import { AnimatePresence, MotionConfig, motion } from 'framer-motion'
+import { Wallet } from 'lucide-react'
+import { useEffect, useState } from 'react'
+
+import { BrandMark } from '../layout/brand-mark'
+import { ConnectSteps } from './connect-dialog'
+import { IntentShowcase } from './intent-showcase'
+
+const WIDE = '(min-width: 1024px)'
+
+/** Only rendered in the browser, so this can read the window at once. */
+function useIsWide(): boolean {
+ const [wide, setWide] = useState(() => window.matchMedia(WIDE).matches)
+ useEffect(() => {
+ const query = window.matchMedia(WIDE)
+ const onChange = (): void => setWide(query.matches)
+ query.addEventListener('change', onChange)
+ return () => query.removeEventListener('change', onChange)
+ }, [])
+ return wide
+}
+
+const chainNames = CONNECT_CHAINS.map((slug) => CHAIN_DESCRIPTORS[slug].name)
+
+/**
+ * The only page a visitor sees without a wallet. The steps replace the button in
+ * place rather than opening over the page, so the slides stay in view throughout.
+ *
+ * The slides are left out of the tree on narrow screens: there is no room for
+ * them, and hidden animations would still spend a phone's battery.
+ */
+export function ConnectPage({
+ onConnected,
+}: {
+ onConnected: (slug: ChainSlug) => void
+}): JSX.Element {
+ const wide = useIsWide()
+ const [connecting, setConnecting] = useState(false)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {connecting ? (
+
+ setConnecting(false)} />
+
+ ) : (
+
+
+ Declare the outcome you want
+
+
+ Connect a wallet to start. Autonomous agents compete to get you the best
+ result.
+
+ setConnecting(true)}
+ className="mt-8 w-full gap-2"
+ >
+
+ Connect wallet
+
+
+ )}
+
+
+
+
+
+
+ {CONNECT_CHAINS.map((slug) => (
+
+ ))}
+
+
+ Works on {chainNames.slice(0, -1).join(', ')} and {chainNames.at(-1)}
+
+
+
+
+ {wide ?
: null}
+
+
+ )
+}
diff --git a/apps/dapp/components/connect/intent-showcase.tsx b/apps/dapp/components/connect/intent-showcase.tsx
new file mode 100644
index 00000000..02330219
--- /dev/null
+++ b/apps/dapp/components/connect/intent-showcase.tsx
@@ -0,0 +1,244 @@
+'use client'
+
+import { cn } from '@intent/ui'
+import {
+ AnimatePresence,
+ motion,
+ motionValue,
+ useAnimationFrame,
+ useReducedMotion,
+ type Transition,
+} from 'framer-motion'
+import { Pause, Play } from 'lucide-react'
+import { useLayoutEffect, useRef, useState, type RefObject } from 'react'
+
+import {
+ ChainRing,
+ isPressed,
+ SETTLED_BEFORE_END_MS,
+ ShowcaseCursor,
+ SLIDES,
+ useBeat,
+ type Slide,
+} from './showcase-states'
+
+/** The states are drawn in this space, then scaled to fit, so they fit every window alike. */
+const STAGE = { width: 500, height: 470 }
+const MIN_SCALE = 0.6
+const MAX_SCALE = 1.6
+
+const MORPH: Transition = { type: 'spring', stiffness: 190, damping: 23 }
+const DIP: Transition = { type: 'spring', stiffness: 520, damping: 32 }
+const INSTANT: Transition = { duration: 0 }
+
+function useStageScale(): [RefObject, number] {
+ const ref = useRef(null)
+ const [scale, setScale] = useState(1)
+
+ useLayoutEffect(() => {
+ const stage = ref.current
+ if (!stage) return
+ const measure = (): void => {
+ const fit = Math.min(stage.clientWidth / STAGE.width, stage.clientHeight / STAGE.height)
+ setScale(Math.min(MAX_SCALE, Math.max(MIN_SCALE, fit)))
+ }
+ measure()
+ const observer = new ResizeObserver(measure)
+ observer.observe(stage)
+ return () => observer.disconnect()
+ }, [])
+
+ return [ref, scale]
+}
+
+/**
+ * Short enough that the swap reads as the shape changing, not as a cut. The old
+ * contents leave first, so two sets of words are never legible over each other.
+ */
+const swap = (reduced: boolean) =>
+ reduced
+ ? {
+ initial: { opacity: 1 },
+ animate: { opacity: 1, transition: INSTANT },
+ exit: { opacity: 0, transition: INSTANT },
+ }
+ : {
+ initial: { opacity: 0, filter: 'blur(8px)' },
+ animate: {
+ opacity: 1,
+ filter: 'blur(0px)',
+ transition: { duration: 0.2, delay: 0.06, ease: 'easeOut' as const },
+ transitionEnd: { filter: 'none' },
+ },
+ exit: {
+ opacity: 0,
+ filter: 'blur(8px)',
+ transition: { duration: 0.1, ease: 'easeIn' as const },
+ },
+ }
+
+/**
+ * What Intent does, told by one white shape that never cuts. Every state is the
+ * same element changing size and corners, and only what it holds is swapped.
+ *
+ * The active bar's animation is the timer: its end is what moves on. The states
+ * read their beats from it, and carry on to their finished picture if the bar is
+ * held by a hover, so that choosing one with the mouse still plays it through.
+ */
+export function IntentShowcase(): JSX.Element {
+ const reduced = useReducedMotion() ?? false
+ const [active, setActive] = useState(0)
+ const [hovered, setHovered] = useState(false)
+ const [stopped, setStopped] = useState(false)
+ const [stageRef, scale] = useStageScale()
+
+ // One clock each, so a state on its way out keeps the picture it had.
+ const [clocks] = useState(() => SLIDES.map(() => motionValue(0)))
+ const slide = SLIDES[active] as Slide
+ const clock = clocks[active] as (typeof clocks)[number]
+ const { Content, shape } = slide
+
+ const barRef = useRef(null)
+ const storyMs = useRef(0)
+ const chosen = useRef(false)
+
+ useLayoutEffect(() => {
+ storyMs.current = 0
+ clock.set(reduced ? Infinity : 0)
+ }, [clock, reduced])
+
+ useAnimationFrame((_, delta) => {
+ if (reduced) return
+ if (!stopped || chosen.current) {
+ storyMs.current = Math.min(storyMs.current + delta, slide.ms - SETTLED_BEFORE_END_MS)
+ }
+ const bar = barRef.current?.getAnimations()[0]
+ const barMs = typeof bar?.currentTime === 'number' ? bar.currentTime : 0
+ const now = Math.max(storyMs.current, barMs)
+ if (stopped && bar) bar.currentTime = now
+ clock.set(now)
+ })
+
+ const pressed = useBeat(clock, (ms) => isPressed(slide.presses, ms))
+
+ const choose = (next: number): void => {
+ if (next === active) return
+ chosen.current = true
+ setActive(next)
+ }
+
+ const togglePlay = (): void => {
+ chosen.current = false
+ setStopped((value) => !value)
+ }
+
+ const swapMotion = swap(reduced)
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ className="showcase-panel bg-brand text-brand-foreground relative isolate m-3 ml-0 flex min-w-0 flex-1 flex-col overflow-hidden rounded-3xl"
+ >
+
+
+ {SLIDES.map((s, i) => (
+ choose(i)}
+ aria-label={`Show slide ${i + 1}: ${s.title}`}
+ aria-current={i === active ? 'true' : undefined}
+ className="focus-visible:ring-brand-foreground group flex-1 rounded py-2 focus-visible:outline-none focus-visible:ring-2"
+ >
+
+ {i < active || (i === active && reduced) ? (
+
+ ) : null}
+ {i === active && !reduced ? (
+ {
+ chosen.current = false
+ setActive((a) => (a + 1) % SLIDES.length)
+ }}
+ className="bg-brand-foreground block h-full w-full origin-left"
+ style={{
+ animation: `showcase-progress ${s.ms}ms linear forwards`,
+ animationPlayState: hovered || stopped ? 'paused' : 'running',
+ }}
+ />
+ ) : null}
+
+
+ ))}
+
+ {!reduced ? (
+
+ {stopped ? : }
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ {!reduced ?
: null}
+
+
+
+
+
+
+
+ {slide.title}
+
+
+ {slide.body}
+
+
+
+
+
+ )
+}
diff --git a/apps/dapp/components/connect/showcase-states.tsx b/apps/dapp/components/connect/showcase-states.tsx
new file mode 100644
index 00000000..f6ca277e
--- /dev/null
+++ b/apps/dapp/components/connect/showcase-states.tsx
@@ -0,0 +1,493 @@
+'use client'
+
+import { CHAIN_DESCRIPTORS, CONNECT_CHAINS } from '@intent/config'
+import { ChainMark, cn } from '@intent/ui'
+import { motion, type MotionValue, type Transition } from 'framer-motion'
+import { ArrowDown, ArrowUp, Check } from 'lucide-react'
+import Image from 'next/image'
+import { useCallback, useSyncExternalStore, type ComponentType } from 'react'
+
+/** Milliseconds into the current state. Infinity shows the state finished, for reduced motion. */
+export type Clock = MotionValue
+
+/**
+ * Everything inside a state is a pure function of its clock, so a jump has no
+ * timers to cancel and nothing to get out of step. Reads must return a primitive.
+ */
+export function useBeat(
+ clock: Clock,
+ read: (ms: number) => T
+): T {
+ const subscribe = useCallback((notify: () => void) => clock.on('change', notify), [clock])
+ const snapshot = (): T => read(clock.get())
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
+}
+
+const PRESS_MS = 170
+/** The press that sets off the next state lands just before the bar runs out. */
+const FINAL_PRESS_BEFORE_END_MS = 300
+/** Where a state's own story ends, and it can hold without the bar. */
+export const SETTLED_BEFORE_END_MS = 420
+
+const SNAP: Transition = { type: 'spring', stiffness: 460, damping: 34 }
+const GLIDE: Transition = { type: 'spring', stiffness: 260, damping: 29 }
+
+export const isPressed = (presses: number[], ms: number): boolean =>
+ presses.some((at) => ms >= at && ms < at + PRESS_MS)
+
+const progress = (ms: number, from: number, to: number): number =>
+ Math.min(1, Math.max(0, (ms - from) / (to - from)))
+
+const easeOut = (x: number): number => 1 - (1 - x) ** 3
+
+function ExampleTag(): JSX.Element {
+ return Example
+}
+
+function Tick({ on }: { on: boolean }): JSX.Element {
+ return (
+
+
+
+
+
+ )
+}
+
+const PILL = { width: 440, height: 76 }
+const TYPED = 'Swap 100 USDC for SOL'
+const TYPE_FROM_MS = 700
+const MS_PER_LETTER = 62
+const TYPED_BY_MS = TYPE_FROM_MS + TYPED.length * MS_PER_LETTER
+const SAY_MS = 5000
+const SAY_PRESSES = [450, SAY_MS - FINAL_PRESS_BEFORE_END_MS]
+const SEND_X = PILL.width / 2 - 14 - 24
+
+function SayContent({ clock }: { clock: Clock }): JSX.Element {
+ const letters = useBeat(clock, (ms) =>
+ Math.floor(progress(ms, TYPE_FROM_MS, TYPED_BY_MS) * TYPED.length)
+ )
+ const caret = useBeat(clock, (ms) => ms >= 450 && ms < 1e7 && Math.floor(ms / 480) % 2 === 0)
+ const sending = useBeat(clock, (ms) => isPressed([SAY_PRESSES[1] as number], ms))
+ const ready = letters === TYPED.length
+
+ return (
+
+
+ {letters > 0 ? (
+ TYPED.slice(0, letters)
+ ) : (
+ Describe the outcome you want…
+ )}
+
+
+
+
+
+
+ )
+}
+
+/** What each agent offers at each round. The lead changes hands, which is the point. */
+const AGENTS: { name: string; quotes: number[] }[] = [
+ { name: 'Agent one', quotes: [0.604, 0.609, 0.612, 0.614] },
+ { name: 'Agent two', quotes: [0.606, 0.607, 0.615, 0.618] },
+ { name: 'Agent three', quotes: [0.601, 0.611, 0.613, 0.616] },
+]
+const ROUND_AT_MS = [0, 1000, 1950, 2900]
+const QUOTE_ROLL_MS = 420
+const DECIDED_MS = 3550
+const QUOTE_FLOOR = 0.596
+const QUOTE_CEILING = 0.62
+
+const CARD = { width: 440, height: 300 }
+const ROW_HEIGHT = 60
+const ROW_GAP = 8
+const COMPETE_MS = 6000
+
+const roundAt = (ms: number): number => ROUND_AT_MS.filter((at) => ms >= at).length - 1
+
+function rankIn(round: number, name: string): number {
+ return [...AGENTS]
+ .sort((a, b) => (b.quotes[round] ?? 0) - (a.quotes[round] ?? 0))
+ .findIndex((agent) => agent.name === name)
+}
+
+function quoteAt(quotes: number[], ms: number): number {
+ const round = roundAt(ms)
+ const now = quotes[round] ?? 0
+ if (round === 0) return now
+ const before = quotes[round - 1] ?? now
+ return (
+ before +
+ (now - before) *
+ progress(ms, ROUND_AT_MS[round] ?? 0, (ROUND_AT_MS[round] ?? 0) + QUOTE_ROLL_MS)
+ )
+}
+
+function AgentRow({
+ clock,
+ name,
+ quotes,
+}: {
+ clock: Clock
+ name: string
+ quotes: number[]
+}): JSX.Element {
+ const round = useBeat(clock, roundAt)
+ const decided = useBeat(clock, (ms) => ms >= DECIDED_MS)
+ const quote = useBeat(clock, (ms) => quoteAt(quotes, ms).toFixed(3))
+ const rank = rankIn(round, name)
+ const won = decided && rank === 0
+ const fill = won
+ ? 100
+ : (((quotes[round] ?? 0) - QUOTE_FLOOR) / (QUOTE_CEILING - QUOTE_FLOOR)) * 100
+
+ return (
+
+
+
+ {name}
+ {quote} SOL
+
+
+
+
+
+
+
+ )
+}
+
+function CompeteContent({ clock }: { clock: Clock }): JSX.Element {
+ return (
+
+
+
+ {AGENTS.map((agent) => (
+
+ ))}
+
+
+ )
+}
+
+const HUB_DIAMETER = 156
+const RING_RADIUS = 158
+const COIN_SIZE = 62
+const ARRIVE_FROM_MS = 380
+const ARRIVE_STAGGER_MS = 120
+const TOUR_FROM_MS = 1350
+const TOUR_STEP_MS = 480
+const TOUR_ENDS_MS = TOUR_FROM_MS + TOUR_STEP_MS * CONNECT_CHAINS.length
+const CHAINS_MS = 6000
+const CHAINS_FINAL_PRESS = CHAINS_MS - FINAL_PRESS_BEFORE_END_MS
+
+/** Each chain lights in turn, finishing back on Stellar, the one the cursor then presses. */
+function litChainAt(ms: number): number {
+ if (ms === Infinity || ms < TOUR_FROM_MS) return -1
+ if (ms >= TOUR_ENDS_MS) return 0
+ return Math.floor((ms - TOUR_FROM_MS) / TOUR_STEP_MS)
+}
+
+function HubContent({ clock }: { clock: Clock }): JSX.Element {
+ const lit = useBeat(clock, litChainAt)
+ const chain = CONNECT_CHAINS[lit]
+ return (
+
+
+
+
+
+ {chain ? CHAIN_DESCRIPTORS[chain].name : null}
+
+
+ )
+}
+
+const ringPoints = CONNECT_CHAINS.map((chain, i) => {
+ const angle = (i / CONNECT_CHAINS.length) * Math.PI * 2 - Math.PI / 2
+ return { chain, x: Math.cos(angle) * RING_RADIUS, y: Math.sin(angle) * RING_RADIUS }
+})
+
+/** Sits behind the shape, so the marks come out from under it and go back the same way. */
+export function ChainRing({
+ clock,
+ on,
+ instant,
+}: {
+ clock: Clock
+ on: boolean
+ instant: boolean
+}): JSX.Element {
+ return (
+
+ {ringPoints.map((point, i) => (
+
+ ))}
+
+ )
+}
+
+function ChainCoin({
+ clock,
+ on,
+ instant,
+ index,
+ chain,
+ x,
+ y,
+}: {
+ clock: Clock
+ on: boolean
+ instant: boolean
+ index: number
+ chain: (typeof CONNECT_CHAINS)[number]
+ x: number
+ y: number
+}): JSX.Element {
+ const arrived = useBeat(clock, (ms) => on && ms >= ARRIVE_FROM_MS + index * ARRIVE_STAGGER_MS)
+ const lit = useBeat(clock, (ms) => on && litChainAt(ms) === index)
+ const pressed = useBeat(clock, (ms) => on && index === 0 && isPressed([CHAINS_FINAL_PRESS], ms))
+ const scale = pressed ? 0.92 : lit ? 1.08 : 1
+
+ return (
+
+
+
+ )
+}
+
+const RECEIPT = { width: 304, height: 404 }
+const NAIRA = 68_500
+const COUNT_FROM_MS = 800
+const COUNT_TO_MS = 2500
+const STEPS = [
+ { label: 'USDC sent', at: 800 },
+ { label: 'Converted to naira', at: 2500 },
+ { label: 'Paid to your bank', at: 3300 },
+]
+const NAIRA_MS = 6000
+
+function ReceiptStep({
+ clock,
+ label,
+ at,
+}: {
+ clock: Clock
+ label: string
+ at: number
+}): JSX.Element {
+ const done = useBeat(clock, (ms) => ms >= at)
+ return (
+
+
+ {label}
+
+ )
+}
+
+function ReceiptContent({ clock }: { clock: Clock }): JSX.Element {
+ const naira = useBeat(clock, (ms) =>
+ Math.round(easeOut(progress(ms, COUNT_FROM_MS, COUNT_TO_MS)) * NAIRA)
+ )
+ return (
+
+
+
+ 50 USDC
+
+
+
You get
+
+ ₦
+ {naira.toLocaleString('en-NG')}
+
+
+ {STEPS.map((step) => (
+
+ ))}
+
+
+ )
+}
+
+export interface Slide {
+ key: string
+ title: string
+ body: string
+ /** The active bar's length, and so how long this state stays. */
+ ms: number
+ shape: { width: number; height: number; radius: number }
+ Content: ComponentType<{ clock: Clock }>
+ /** Where the cursor heads, from each moment on. Relative to the shape's centre. */
+ cursor: { at: number; x: number; y: number }[]
+ presses: number[]
+}
+
+export const SLIDES: Slide[] = [
+ {
+ key: 'say',
+ title: 'Say what you want',
+ body: 'Type the outcome in plain words. Intent works out the steps, and you sign.',
+ ms: SAY_MS,
+ shape: { ...PILL, radius: PILL.height / 2 },
+ Content: SayContent,
+ cursor: [
+ { at: 0, x: -150, y: 8 },
+ { at: 800, x: -40, y: 66 },
+ { at: 2300, x: SEND_X, y: 8 },
+ ],
+ presses: SAY_PRESSES,
+ },
+ {
+ key: 'compete',
+ title: 'Agents compete for your trade',
+ body: 'Autonomous agents bid on your intent, and the best result wins.',
+ ms: COMPETE_MS,
+ shape: { ...CARD, radius: 30 },
+ Content: CompeteContent,
+ cursor: [
+ { at: 0, x: 176, y: 176 },
+ { at: 4000, x: 48, y: -34 },
+ ],
+ presses: [COMPETE_MS - FINAL_PRESS_BEFORE_END_MS],
+ },
+ {
+ key: 'chains',
+ title: 'Six chains, one place',
+ body: 'Connect on Stellar, Solana, Avalanche, Sui, Monad or NEAR.',
+ ms: CHAINS_MS,
+ shape: { width: HUB_DIAMETER, height: HUB_DIAMETER, radius: HUB_DIAMETER / 2 },
+ Content: HubContent,
+ cursor: [
+ { at: 0, x: 92, y: 104 },
+ { at: 4000, x: 8, y: -150 },
+ ],
+ presses: [CHAINS_FINAL_PRESS],
+ },
+ {
+ key: 'naira',
+ title: 'Cash out to naira',
+ body: 'Turn USDC into naira in your bank account. Available on Stellar today.',
+ ms: NAIRA_MS,
+ shape: { ...RECEIPT, radius: 30 },
+ Content: ReceiptContent,
+ cursor: [
+ { at: 0, x: 178, y: 104 },
+ { at: 3900, x: 44, y: 168 },
+ ],
+ presses: [NAIRA_MS - FINAL_PRESS_BEFORE_END_MS],
+ },
+]
+
+export function ShowcaseCursor({ clock, slide }: { clock: Clock; slide: Slide }): JSX.Element {
+ const leg = useBeat(clock, (ms) => slide.cursor.filter((point) => ms >= point.at).length - 1)
+ const pressed = useBeat(clock, (ms) => isPressed(slide.presses, ms))
+ const point = slide.cursor[leg] ?? slide.cursor[0]
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/dapp/components/connect/wallet-gate.tsx b/apps/dapp/components/connect/wallet-gate.tsx
new file mode 100644
index 00000000..144cfc3f
--- /dev/null
+++ b/apps/dapp/components/connect/wallet-gate.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { homeSegment, isMultiNetwork, parseChainSegment } from '@intent/config'
+import { usePathname, useRouter } from 'next/navigation'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+
+import { useGoToChain } from '../../hooks/use-go-to-chain'
+import { useWallet } from '../../hooks/use-wallet'
+import { forgetChain, rememberChain } from '../../lib/wallet/chain-cookie'
+import { connectAddress } from '../../lib/wallet/chain-path'
+import { useChain } from '../../providers/chain-provider'
+import { ConnectPage } from './connect-page'
+
+/**
+ * How long "not connected" must stand before it is acted on. wagmi only starts
+ * reconnecting a moment after mounting, and without this a returning visitor
+ * would be sent away before their own wallet comes back.
+ */
+const SETTLE_MS = 250
+
+/**
+ * The app is reachable only with a wallet connected on the chain in the address.
+ * Without one, the visitor goes to the connect page at the bare address, which
+ * brings them back to the screen they asked for.
+ *
+ * This decides what renders, not what is allowed: wallets live in the browser,
+ * so no server can check one here. API routes keep their own checks.
+ */
+export function WalletGate({
+ children,
+ bypass = false,
+}: {
+ children: ReactNode
+ bypass?: boolean
+}): JSX.Element {
+ const { segment, slug, network } = useChain()
+ const { isConnected, isRestoring } = useWallet()
+ const pathname = usePathname()
+ const router = useRouter()
+ const [settled, setSettled] = useState(false)
+ const wasConnected = useRef(false)
+ if (isConnected) wasConnected.current = true
+
+ // The bare address connects Stellar on the network the app opens on. On the
+ // other one, the address is the only thing saying which network was chosen,
+ // so the connect page stays here rather than losing that.
+ const onOtherStellarNetwork =
+ slug === 'stellar' &&
+ isMultiNetwork() &&
+ network !== undefined &&
+ network !== parseChainSegment(homeSegment())?.network
+
+ const waiting = !settled || isRestoring === true
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setSettled(true), SETTLE_MS)
+ return () => window.clearTimeout(timer)
+ }, [])
+
+ useEffect(() => {
+ if (isConnected) rememberChain(segment)
+ }, [isConnected, segment])
+
+ useEffect(() => {
+ if (isConnected || bypass || waiting || onOtherStellarNetwork) return
+ // Forgotten first, or the bare address would send them straight back here.
+ forgetChain()
+ router.replace(wasConnected.current ? '/' : connectAddress(pathname, window.location.search))
+ }, [isConnected, bypass, waiting, onOtherStellarNetwork, pathname, router])
+
+ if (isConnected || bypass) return <>{children}>
+ if (!waiting && onOtherStellarNetwork) return
+ return
+}
+
+function ConnectHere(): JSX.Element {
+ const goToChain = useGoToChain()
+ return
+}
diff --git a/apps/dapp/components/intents/borrow-confirm.tsx b/apps/dapp/components/intents/borrow-confirm.tsx
index a7bdb244..cb2f6e6b 100644
--- a/apps/dapp/components/intents/borrow-confirm.tsx
+++ b/apps/dapp/components/intents/borrow-confirm.tsx
@@ -1,5 +1,6 @@
'use client'
+import { isMainnet } from '@intent/config'
import { Button, Card } from '@intent/ui'
import { CheckCircle2, ExternalLink, Loader2, TriangleAlert } from 'lucide-react'
@@ -215,10 +216,12 @@ export function BorrowConfirm({
) : null}
-
- Testnet prices come from the pool's own oracle and are set by its administrators, so
- these figures describe testnet only.
-
+ {isMainnet() ? null : (
+
+ Testnet prices come from the pool's own oracle and are set by its administrators, so
+ these figures describe testnet only.
+
+ )}
diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx
new file mode 100644
index 00000000..71992603
--- /dev/null
+++ b/apps/dapp/components/intents/cashout-card.tsx
@@ -0,0 +1,837 @@
+'use client'
+
+import { accountExplorerUrl } from '@intent/config'
+import { cn } from '@intent/ui'
+import { useQuery } from '@tanstack/react-query'
+import { motion } from 'framer-motion'
+import {
+ ArrowDown,
+ ArrowRight,
+ BadgeCheck,
+ Banknote,
+ Check,
+ ExternalLink,
+ Loader2,
+ ShieldCheck,
+ Timer,
+ TriangleAlert,
+} from 'lucide-react'
+import { useEffect, useMemo, useState, type ReactNode } from 'react'
+
+import { fetchCashoutCurrencies, type Cashout } from '../../hooks/use-cashout'
+import { useWallet } from '../../hooks/use-wallet'
+import { walletFetch } from '../../lib/api/network-fetch'
+import {
+ accountHint,
+ canSign,
+ CHECK_WALLET,
+ cleanIdentifier,
+ depositExplorerUrl,
+ DO_NOT_RESEND,
+ explorerName,
+ formatPayout,
+ formatUsdc,
+ isRealHolderName,
+ isValidAmount,
+ NO_CASHOUTS,
+ shouldPoll,
+ statusText,
+ withBankLogos,
+ type CashoutInstitution,
+ type CashoutOrderView,
+ type CashoutQuote,
+} from '../../lib/weave/cashout-view'
+import { isCashoutChain } from '../../lib/weave/types'
+import { useChain } from '../../providers/chain-provider'
+import { TokenIcon } from '../ui/token-icon'
+import { BankMark, BankPicker, type Bank } from './bank-picker'
+import { toolCard, toolPanel } from './bubble'
+
+/**
+ * The cash-out form on every chain Weave pays out from.
+ *
+ * Its screens are the naira form's, copied from `naira-payout-card.tsx` so a
+ * person sees one form whichever chain they are on. Only what that form says about
+ * Linq in particular differs, where it would be untrue of Weave: the rate is an
+ * estimate here and is not locked.
+ */
+
+const SHELL = toolCard
+
+const FIELD =
+ 'border-bubble-agent-border bg-background/70 focus-within:border-brand/60 focus-within:ring-brand/15 rounded-2xl border transition-all focus-within:ring-4 dark:border-white/[0.07] dark:bg-[#121212]'
+
+/** How long an order waits for its deposit when Weave doesn't say. */
+const PAY_WINDOW_MS = 10 * 60 * 1000
+
+function titleCase(name: string): string {
+ return name.toLowerCase().replace(/(^|[\s'-])\p{L}/gu, (m) => m.toUpperCase())
+}
+
+function CardHeader({
+ title,
+ subtitle,
+ tone = 'brand',
+}: {
+ title: string
+ subtitle?: string
+ tone?: 'brand' | 'verified' | 'warning'
+}): JSX.Element {
+ return (
+
+ {tone === 'verified' ? (
+
+ ) : (
+
+ {tone === 'warning' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {title}
+ {subtitle !== undefined ? (
+ {subtitle}
+ ) : null}
+
+
+ )
+}
+
+function PayoutProgress({
+ order,
+ bank,
+ hash,
+ explorerUrl,
+ unsure,
+ onRetry,
+}: {
+ order: CashoutOrderView
+ bank?: Bank | undefined
+ hash?: string | undefined
+ explorerUrl?: string | undefined
+ /** Set while a transfer may have gone out and its outcome isn't known. */
+ unsure?: string | undefined
+ onRetry: () => void
+}): JSX.Element {
+ const done = order.status === 'paid'
+ const running = shouldPoll(order.status)
+ const bad = !done && !running
+ const reached = order.status === 'waiting' ? 0 : order.status === 'processing' ? 1 : 2
+ const isNaira = order.currency === 'NGN'
+ const steps = ['Deposit', isNaira ? 'Bank transfer' : 'Payout', 'Paid']
+ const known = order.depositHash ?? hash
+ const href =
+ known === undefined || known === ''
+ ? undefined
+ : explorerUrl !== undefined && explorerUrl !== ''
+ ? explorerUrl
+ : isCashoutChain(order.chain)
+ ? depositExplorerUrl(order.chain, known)
+ : undefined
+ return (
+
+
+
+ {unsure !== undefined && running ? (
+
+ {unsure}
+
+ ) : null}
+
+ {running ? (
+
+ {steps.map((label, i) => {
+ const complete = i < reached
+ const current = i === reached
+ return (
+
+
+
+ {complete ? : null}
+ {current ? (
+
+ ) : null}
+ {label}
+
+
+ )
+ })}
+
+ ) : null}
+ {bad ? (
+
+ Try the payout again
+
+ ) : null}
+
+
+
+
+ {formatUsdc(order.amount)} USDC
+
+
+
+ {bank !== undefined ? : null}
+ {formatPayout(order.estimatedDest, order.currency)}
+
+
+
+
+
+
Account holder
+
+ {isRealHolderName(order.accountName) ? order.accountName : 'Not confirmed'}
+
+
+
+
{isNaira ? 'Bank' : 'Paid to'}
+
+ {order.institutionName} {order.accountMask}
+
+
+
+
+ {href !== undefined && isCashoutChain(order.chain) ? (
+
+ View payment on {explorerName(order.chain)}
+
+
+ ) : null}
+
+ {running ? (
+
+ Your USDC has left or is leaving your wallet. {DO_NOT_RESEND}
+
+ ) : null}
+
+ )
+}
+
+function BeforeYouSign({
+ address,
+ expiresAt,
+}: {
+ address: string
+ expiresAt?: string | undefined
+}): JSX.Element {
+ const [deadline] = useState(() => {
+ const stated = expiresAt !== undefined ? Date.parse(expiresAt) : Number.NaN
+ return Number.isNaN(stated) ? Date.now() + PAY_WINDOW_MS : stated
+ })
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 1000)
+ return () => window.clearInterval(id)
+ }, [])
+
+ const left = Math.max(0, deadline - now)
+ const expired = left === 0
+ const mm = Math.floor(left / 60_000)
+ const ss = String(Math.floor((left % 60_000) / 1000)).padStart(2, '0')
+
+ return (
+
+
+
+
+
+
+ {expired ? 'Time is up' : 'Pay within'}
+
+ {expired
+ ? 'This payout will be cancelled. Use Edit to start a new one.'
+ : 'Unpaid orders are cancelled. Nothing is taken.'}
+
+
+
+ {mm}:{ss}
+
+
+
+
+
+
+
+
+ Match the address in your wallet
+
+
+ {address.slice(0, 6)}
+
+ ···
+
+ {address.slice(-6)}
+
+
+
+ A one-time deposit address made for this payout, so a wallet security scan may flag it
+ as new.
+
+
+
+
+ )
+}
+
+function Message({ children }: { children: ReactNode }): JSX.Element {
+ return {children}
+}
+
+const BACK_BUTTON =
+ 'border-bubble-agent-foreground/15 hover:bg-bubble-agent-foreground/5 w-fit rounded-full border px-4 py-2 text-sm transition-colors'
+
+/**
+ * The account number is held here and in the requests that check and use it, and
+ * nowhere else: not in the chat, its history or any log.
+ */
+export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | null {
+ const { slug, descriptor } = useChain()
+ const { address, isWrongNetwork, switchNetwork, isSwitching } = useWallet()
+ const seed = cashout.session?.seed
+
+ const [amount, setAmount] = useState(seed?.amount ?? '')
+ const [settledAmount, setSettledAmount] = useState(seed?.amount ?? '')
+ const [bankCode, setBankCode] = useState('')
+ const [accountNumber, setAccountNumber] = useState('')
+ const [settledAccount, setSettledAccount] = useState('')
+
+ useEffect(() => {
+ const id = window.setTimeout(() => setSettledAmount(amount), 400)
+ return () => window.clearTimeout(id)
+ }, [amount])
+ useEffect(() => {
+ const id = window.setTimeout(() => setSettledAccount(accountNumber), 500)
+ return () => window.clearTimeout(id)
+ }, [accountNumber])
+
+ const options = useQuery({
+ queryKey: ['cashout-options', slug],
+ queryFn: () => fetchCashoutCurrencies(slug),
+ staleTime: 60_000,
+ retry: false,
+ })
+ const currencies = options.data
+ const seedAvailable =
+ seed?.currency !== undefined && currencies?.some((c) => c.code === seed.currency) === true
+ const currency = seedAvailable
+ ? (seed?.currency as string)
+ : (currencies?.find((c) => c.code === 'NGN')?.code ?? currencies?.[0]?.code ?? '')
+ const corridor = currencies?.find((c) => c.code === currency)
+ const isNaira = currency === 'NGN'
+
+ const institutions = useQuery({
+ queryKey: ['cashout-institutions', currency],
+ queryFn: async () => {
+ const res = await walletFetch(
+ `/api/cashout/institutions?currency=${encodeURIComponent(currency)}`
+ )
+ const body = (await res.json()) as { institutions?: CashoutInstitution[]; message?: string }
+ if (!res.ok || body.institutions === undefined) {
+ throw new Error(body.message ?? 'Could not load the banks.')
+ }
+ return body.institutions
+ },
+ enabled: currency !== '',
+ staleTime: 300_000,
+ retry: false,
+ })
+
+ // The same list the naira form reads, for the same logos on the same banks.
+ const nairaBanks = useQuery({
+ queryKey: ['naira-banks'],
+ queryFn: async () =>
+ ((await (await fetch('/api/naira/banks')).json()) as { banks: Bank[] }).banks,
+ staleTime: Infinity,
+ enabled: isNaira,
+ })
+
+ const banks = useMemo(
+ () =>
+ institutions.data === undefined
+ ? undefined
+ : withBankLogos(institutions.data, isNaira ? (nairaBanks.data ?? []) : [], currency),
+ [institutions.data, nairaBanks.data, isNaira, currency]
+ )
+
+ const balanceQuery = useQuery({
+ queryKey: ['cashout-balance', slug, address],
+ queryFn: async () => {
+ const params = new URLSearchParams({ chain: slug, account: address as string })
+ const res = await walletFetch(`/api/cashout/balance?${params.toString()}`)
+ const body = (await res.json()) as { balance?: string }
+ if (!res.ok || body.balance === undefined) throw new Error('No balance.')
+ return body.balance
+ },
+ enabled: address !== undefined,
+ staleTime: 15_000,
+ retry: false,
+ })
+ const balance = balanceQuery.data
+
+ const quoteReady = currency !== '' && isValidAmount(settledAmount)
+ const quote = useQuery({
+ queryKey: ['cashout-quote', slug, currency, settledAmount],
+ queryFn: async () => {
+ const params = new URLSearchParams({ chain: slug, currency, amount: settledAmount })
+ const res = await walletFetch(`/api/cashout/quote?${params.toString()}`)
+ const body = (await res.json()) as Partial & { message?: string }
+ if (!res.ok || body.destAmount === undefined) {
+ throw new Error(body.message ?? 'No estimate is available for this amount.')
+ }
+ return body as CashoutQuote
+ },
+ enabled: quoteReady,
+ staleTime: 20_000,
+ retry: false,
+ })
+
+ const picked = institutions.data?.find((i) => i.code === bankCode)
+ const hint = accountHint(picked?.type, currency)
+ const accountToCheck = isNaira ? accountNumber : settledAccount
+ const accountReady =
+ bankCode !== '' && (isNaira ? /^\d{10}$/.test(accountToCheck) : accountToCheck.length >= 6)
+ const holder = useQuery({
+ queryKey: ['cashout-holder', currency, bankCode, accountToCheck],
+ queryFn: async () => {
+ const res = await walletFetch('/api/cashout/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ currency,
+ institution: bankCode,
+ accountIdentifier: accountToCheck,
+ }),
+ })
+ const body = (await res.json()) as { accountName?: string; message?: string }
+ if (!res.ok || body.accountName === undefined) {
+ throw new Error(body.message ?? 'That account could not be verified.')
+ }
+ return body.accountName
+ },
+ enabled: accountReady,
+ staleTime: Infinity,
+ retry: false,
+ })
+
+ const { phase, order } = cashout
+ const reviewBank = banks?.find((b) => b.code === bankCode || b.name === order?.institutionName)
+
+ if (cashout.session === null) return null
+
+ if ((phase === 'tracking' || phase === 'pending') && order !== undefined) {
+ return (
+
+ )
+ }
+
+ if (phase === 'check' && order !== undefined) {
+ return (
+
+
+
+ {CHECK_WALLET}
+
+
+ If your wallet shows it was sent, {DO_NOT_RESEND.toLowerCase()} This page keeps following
+ the order.
+
+
+ I checked, try again
+
+
+ )
+ }
+
+ if (
+ (phase === 'review' || phase === 'signing' || phase === 'submitting') &&
+ order !== undefined
+ ) {
+ const busy = phase !== 'review'
+ const destination = Number(order.estimatedDest)
+ return (
+
+
+
+
+
You send
+
{formatUsdc(order.amount)} USDC
+ {reviewBank !== undefined ? (
+
+ ) : (
+
+
+
+ )}
+
+ {isRealHolderName(order.accountName) ? order.accountName : 'This account'} receives
+
+
+ ≈ {formatPayout(destination, order.currency)}
+
+
+
+
+ {(
+ [
+ [
+ 'Rate',
+ `≈ ${formatPayout(destination / Number(order.amount), order.currency)} per USDC`,
+ ],
+ [
+ 'Account holder',
+ isRealHolderName(order.accountName) ? order.accountName : 'Not confirmed',
+ ],
+ [isNaira ? 'Bank' : 'Paid to', `${order.institutionName} ${order.accountMask}`],
+ ] as const
+ ).map(([k, v]) => (
+
+
{k}
+ {v}
+
+ ))}
+
+
+
+
+
+ {cashout.error !== undefined ? (
+
+ {cashout.error}
+
+ ) : null}
+
+
+
+ {busy ? : null}
+ {isWrongNetwork
+ ? `Switch your wallet to ${descriptor.name}`
+ : phase === 'signing'
+ ? 'Waiting for your wallet…'
+ : phase === 'submitting'
+ ? 'Sending…'
+ : 'Approve and sign'}
+
+
+ Edit
+
+
+
+ )
+ }
+
+ if (phase === 'failed') {
+ return (
+
+
+
+ {cashout.error ?? 'The payout did not go through.'}
+
+
+ {order !== undefined ? 'Back to the review' : 'Back to the form'}
+
+
+ )
+ }
+
+ if (options.isLoading) {
+ return (
+
+
+ Checking where you can cash out…
+
+ )
+ }
+
+ if (currencies === undefined || currencies.length === 0) {
+ return (
+
+ {NO_CASHOUTS}
+
+ Close
+
+
+ )
+ }
+
+ const send = Number(amount)
+ const maxAmount = balance !== undefined ? Number(balance) : undefined
+ const overBalance = maxAmount !== undefined && send > maxAmount
+ const estimate = quoteReady && quote.data !== undefined ? quote.data : undefined
+ const canReview =
+ isValidAmount(amount) && estimate !== undefined && !overBalance && holder.data !== undefined
+ const bankName = banks?.find((b) => b.code === bankCode)?.name
+ const creating = phase === 'creating'
+
+ return (
+
+
+
+ {seed?.currency !== undefined && !seedAvailable ? (
+
+ {seed.currency} isn't available here, so this pays out in {currency}.
+
+ ) : null}
+
+
+
+
+ You send
+ {balance !== undefined ? (
+
+ Available {Number(balance).toLocaleString('en-US', { maximumFractionDigits: 2 })}
+ {maxAmount !== undefined && maxAmount > 0 ? (
+ setAmount(String(Math.floor(maxAmount * 100) / 100))}
+ className="bg-bubble-me text-bubble-me-foreground hover:bg-bubble-me/80 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide transition-colors"
+ >
+ Max
+
+ ) : null}
+
+ ) : null}
+
+
+ setAmount(e.target.value.replace(/[^\d.]/g, ''))}
+ placeholder="0.00"
+ className="placeholder:text-muted-foreground/50 min-w-0 flex-1 bg-transparent text-2xl font-semibold tabular-nums outline-none"
+ />
+
+
+ USDC
+
+
+
+ {estimate !== undefined ? (
+
+
+ ≈{' '}
+
+ {formatPayout(estimate.destAmount, currency)}
+
+
+
+ Estimate. You see the exact amount before you sign.
+
+
+ ) : (
+
+ {quote.isError && quoteReady
+ ? (quote.error as Error).message
+ : 'Enter an amount to see what the bank receives.'}
+
+ )}
+
+
+ {overBalance ? (
+
That is more USDC than the account holds.
+ ) : null}
+
+
+
+
+
+ {isNaira ? 'Bank' : 'Bank or mobile money'}
+
+
+ {institutions.isError ? (
+
{(institutions.error as Error).message}
+ ) : null}
+
+
+
+ {isNaira ? 'Account number' : hint.label}
+ {isNaira ? {accountNumber.length}/10 : null}
+
+
+
+ setAccountNumber(
+ isNaira ? e.target.value.replace(/\D/g, '') : cleanIdentifier(e.target.value)
+ )
+ }
+ placeholder={isNaira ? '0123456789' : hint.placeholder}
+ className="placeholder:text-muted-foreground/50 min-w-0 flex-1 bg-transparent text-sm tabular-nums tracking-[0.15em] outline-none"
+ />
+
+
+ {holder.isFetching ? (
+
+
+ Checking with the bank…
+
+ ) : holder.data !== undefined && accountReady ? (
+
+ {isRealHolderName(holder.data) ? (
+ <>
+ {titleCase(holder.data)}
+
+ >
+ ) : (
+
+ The bank can't confirm a name here, so check this number carefully.
+
+ )}
+
+ ) : holder.isError ? (
+ {(holder.error as Error).message}
+ ) : null}
+
+
+
+
+
+ cashout.create({
+ currency,
+ institution: bankCode,
+ accountIdentifier: accountNumber,
+ amount,
+ })
+ }
+ className="bg-bubble-me text-bubble-me-foreground hover:bg-brand hover:text-brand-foreground disabled:hover:bg-bubble-me disabled:hover:text-bubble-me-foreground flex h-12 items-center justify-center gap-2 rounded-full text-sm font-medium transition-colors disabled:opacity-50"
+ >
+ {creating ? : null}
+ {creating
+ ? 'Setting up your payout…'
+ : bankName !== undefined
+ ? `Review payout to ${bankName}`
+ : 'Review payout'}
+
+
+ )
+}
diff --git a/apps/dapp/components/intents/cashout-currency-question.tsx b/apps/dapp/components/intents/cashout-currency-question.tsx
new file mode 100644
index 00000000..e42460b9
--- /dev/null
+++ b/apps/dapp/components/intents/cashout-currency-question.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { cn } from '@intent/ui'
+
+import { CURRENCY_QUESTION, type CashoutCurrency } from '../../lib/weave/cashout-view'
+import { agentBubble, userBubble } from './bubble'
+
+export interface CashoutQuestionState {
+ amount?: string
+ choices: CashoutCurrency[]
+ /** What the person answered. Set once, and the question stays in the thread as asked. */
+ answer?: string
+ /** An answer that couldn't be used, and what was said back. */
+ retry?: { said: string; reply: string }
+}
+
+/** "Which currency?", answered by tapping one or by typing it in the composer. */
+export function CashoutCurrencyQuestion({
+ question,
+ onChoose,
+}: {
+ question: CashoutQuestionState
+ onChoose: (currency: CashoutCurrency) => void
+}): JSX.Element {
+ const open = question.answer === undefined
+ return (
+ <>
+
+ {question.retry !== undefined ? (
+ <>
+
+
{question.retry.said}
+
+
+
{question.retry.reply}
+
+ >
+ ) : null}
+ {open ? (
+
+ {question.choices.map((choice) => (
+ onChoose(choice)}
+ className={cn(
+ 'border-bubble-agent-border hover:bg-bubble-me hover:text-bubble-me-foreground rounded-full border px-3.5 py-2 text-sm transition-colors dark:border-white/[0.07]'
+ )}
+ >
+ {choice.name} ({choice.code})
+
+ ))}
+
+ ) : (
+
+ )}
+ >
+ )
+}
diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx
new file mode 100644
index 00000000..9011730c
--- /dev/null
+++ b/apps/dapp/components/intents/chain-action-confirm.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+import { Button, Card } from '@intent/ui'
+import { ExternalLink, Loader2 } from 'lucide-react'
+
+import type { ChainAction } from '../../hooks/use-chain-action'
+
+/** Review, sign and result for an action on Solana, Sui or NEAR. */
+export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element {
+ const { phase, prepared, error, hash, explorerUrl } = action
+ const busy = phase === 'signing' || phase === 'submitting'
+
+ return (
+
+ {phase === 'building' ? (
+
+
+ Getting a live price
+
+ ) : null}
+
+ {phase === 'failed' ? (
+ <>
+
+ {error}
+
+
+
+ Dismiss
+
+
+ >
+ ) : null}
+
+ {phase === 'pending' ? (
+ <>
+
+ {error}
+
+
+
+ Done
+
+
+ >
+ ) : null}
+
+ {prepared !== undefined && phase !== 'failed' && phase !== 'pending' ? (
+ <>
+
+ {prepared.summary}
+
+ You pay {prepared.pay.amount} {prepared.pay.symbol} on {prepared.venueName}
+ {prepared.receive !== undefined
+ ? `, and receive ${prepared.receive.approximate ? 'about ' : ''}${prepared.receive.amount} ${prepared.receive.symbol}`
+ : ''}
+
+ {prepared.apy !== undefined ? (
+
+ Current rate {prepared.apy.basePercent.toFixed(2)}%
+ {prepared.apy.rewardPercent !== undefined
+ ? `, plus ${prepared.apy.rewardPercent.toFixed(2)}% in rewards that can end`
+ : ''}
+ . Rates change.
+
+ ) : null}
+
+
+ {prepared.notes.length > 0 ? (
+
+ {prepared.notes.map((note) => (
+ {note}
+ ))}
+
+ ) : null}
+
+ {phase === 'settled' ? (
+
+ Sent
+ {explorerUrl !== undefined ? (
+
+ {hash !== undefined ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : 'View'}
+
+
+ ) : null}
+
+ ) : (
+
+
+ {busy ? (
+
+
+ {phase === 'signing' ? 'Waiting for your wallet' : 'Sending'}
+
+ ) : (
+ 'Sign and send'
+ )}
+
+ {!busy ? (
+
+ Cancel
+
+ ) : null}
+
+ )}
+ >
+ ) : null}
+
+ )
+}
diff --git a/apps/dapp/components/intents/coin-stat.tsx b/apps/dapp/components/intents/coin-stat.tsx
new file mode 100644
index 00000000..fb6338ac
--- /dev/null
+++ b/apps/dapp/components/intents/coin-stat.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import { ChainMark } from '@intent/ui'
+import type { ReactNode } from 'react'
+
+import { useChain } from '../../providers/chain-provider'
+
+/** The chain's logo with a figure over a label, set like the chat's identity beside it. */
+export function CoinStat({
+ figure,
+ label,
+ title,
+}: {
+ figure: string
+ label: ReactNode
+ title: string
+}): JSX.Element {
+ const { slug } = useChain()
+ return (
+
+
+
+ {figure}
+
+ {label}
+
+
+
+ )
+}
diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx
index aae08177..629b1cd4 100644
--- a/apps/dapp/components/intents/intent-chat.tsx
+++ b/apps/dapp/components/intents/intent-chat.tsx
@@ -52,6 +52,27 @@ import { StandingInboxPanel } from './standing-inbox-panel'
import { useStandingRules } from '../../hooks/use-standing-rules'
import { useStandingInbox } from '../../hooks/use-standing-inbox'
import { ensureSession } from '../../lib/api/auth'
+import { chainCapabilities } from '../../lib/chains/capabilities'
+import { chainHelp } from '../../lib/chains/explain'
+import { looksLikeChainAction, parseChainAction } from '../../lib/chains/parse-action'
+import { useChainAction } from '../../hooks/use-chain-action'
+import { ChainActionConfirm } from './chain-action-confirm'
+import { fetchCashoutCurrencies, useCashout } from '../../hooks/use-cashout'
+import { CashoutCard } from './cashout-card'
+import { CashoutCurrencyQuestion, type CashoutQuestionState } from './cashout-currency-question'
+import {
+ cashoutChoices,
+ NO_CASHOUTS,
+ unsupportedCurrencyReply,
+ unsupportedTokenReply,
+} from '../../lib/weave/cashout-view'
+import {
+ parseCashout,
+ parseCurrencyAnswer,
+ routeCashout,
+ type CashoutCurrencyCode,
+ type CashoutRoute,
+} from '../../lib/weave/parse-cashout'
import {
cashoutFormOutcome,
cashoutOutcome,
@@ -110,6 +131,7 @@ 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'
import { agentBubble, systemPill, userBubble } from './bubble'
import { AgentAvatar } from '../agents/agent-avatar'
@@ -281,7 +303,7 @@ export function IntentChat(): JSX.Element {
const slippageEdit =
strategy.slippageBps !== undefined ? { slippageBps: strategy.slippageBps } : {}
- const { slug } = useChain()
+ const { slug, descriptor } = useChain()
const { address, isConnected, balance: nativeBalance, balanceSymbol } = useWallet()
// Checked once the wallet that owns it is known — historyOwner, which
@@ -433,6 +455,9 @@ export function IntentChat(): JSX.Element {
// Supplying an asset already held. A separate shape from a sequence: there is
// no earlier step whose output has to be carried.
const supply = useSupply()
+ const chainAction = useChainAction()
+ const cashout = useCashout()
+ const [cashoutQuestion, setCashoutQuestion] = useState(null)
// A leveraged position on Noether. Its own shape again: no route, no
// competition, and a sign-in with the venue before anything is built.
const perp = usePerp()
@@ -1294,6 +1319,124 @@ export function IntentChat(): JSX.Element {
})()
}
+ /** A cash-out read from the composer: the card, or a plain answer about what it can't do. */
+ function startCashout(route: Exclude, text: string): void {
+ if (executionInFlight) {
+ setAffordError('Finish or cancel the signature first, then continue the chat.')
+ return
+ }
+ setMessage(text)
+ setRestored(null)
+ setHistoryOpen(false)
+ setTurnId(null)
+ setParsed(null)
+ setFollowOn(null)
+ setPending(null)
+ setClarifying(null)
+ setCashoutQuestion(null)
+ setAffordError(null)
+ setNairaForm(null)
+ sequence.reset()
+ supply.reset()
+ perp.reset()
+
+ if (route.kind === 'ask') {
+ if (cashout.locked) {
+ chainAction.refuse(
+ 'A cash-out is already under way. Follow it here, and do not send it again.',
+ text
+ )
+ return
+ }
+ cashout.dismiss()
+ chainAction.reset()
+ setClarifyBusy(true)
+ void fetchCashoutCurrencies(slug).then((weave) => {
+ setClarifyBusy(false)
+ const choices = cashoutChoices(slug, weave)
+ const only = choices.length === 1 ? choices[0] : undefined
+ if (choices.length === 0) {
+ chainAction.refuse(NO_CASHOUTS, text)
+ } else if (only !== undefined) {
+ // Nothing to ask about when one currency is all there is.
+ payOutIn(only.code, route.amount, text)
+ } else {
+ setCashoutQuestion({
+ choices,
+ ...(route.amount !== undefined ? { amount: route.amount } : {}),
+ })
+ }
+ })
+ return
+ }
+
+ if (route.kind === 'open') {
+ if (cashout.locked) {
+ chainAction.refuse(
+ 'A cash-out is already under way. Follow it here, and do not send it again.',
+ text
+ )
+ } else if (slug === 'stellar' && !isMainnet()) {
+ chainAction.refuse(
+ 'Cash-outs pay real money and run on Stellar mainnet only. Switch to mainnet to cash out.',
+ text
+ )
+ } else {
+ chainAction.reset()
+ cashout.open({
+ ...(route.amount !== undefined ? { amount: route.amount } : {}),
+ ...(route.currency !== undefined ? { currency: route.currency } : {}),
+ })
+ }
+ return
+ }
+
+ cashout.dismiss()
+ if (route.kind === 'token') {
+ chainAction.refuse(unsupportedTokenReply(route.token), text)
+ return
+ }
+ void fetchCashoutCurrencies(slug).then((currencies) => {
+ chainAction.refuse(unsupportedCurrencyReply(route.currency, currencies), text)
+ })
+ }
+
+ /** The currency is settled: the form that pays it. Naira on Stellar stays on the Linq form. */
+ function payOutIn(currency: string, amount: string | undefined, text: string): void {
+ chainAction.reset()
+ if (slug === 'stellar' && currency === 'NGN') {
+ const id = crypto.randomUUID()
+ setTurnId(id)
+ saveTurn({
+ id,
+ chain: slug,
+ text,
+ createdAt: new Date().toISOString(),
+ proposals: {},
+ winner: null,
+ })
+ setTurns(loadTurns(slug))
+ setNairaForm({
+ key: Date.now(),
+ path: 'xlm',
+ asset: 'usdc',
+ ...(amount !== undefined ? { amountXlm: Number(amount) } : {}),
+ })
+ return
+ }
+ cashout.open({
+ ...(amount !== undefined ? { amount } : {}),
+ currency: currency as CashoutCurrencyCode,
+ })
+ }
+
+ function answerCashoutQuestion(currency: string, said: string): void {
+ const asked = cashoutQuestion
+ if (asked === null) return
+ setCashoutQuestion({ ...asked, answer: said })
+ payOutIn(currency, asked.amount, message ?? said)
+ }
+
/**
* One request or answer from the composer.
*
@@ -1306,6 +1449,57 @@ export function IntentChat(): JSX.Element {
function handleSubmit(typed: string): void {
// A misspelled verb is corrected before anything reads the sentence.
const text = fixVerbTypo(typed)
+
+ const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet())
+ if (cashoutRoute.kind !== 'pass') {
+ startCashout(cashoutRoute, text)
+ return
+ }
+
+ // The answer to "which currency?". Anything that isn't one closes the question
+ // and is read as what it is, so nobody is held in it.
+ if (cashoutQuestion !== null && cashoutQuestion.answer === undefined) {
+ const answer = parseCurrencyAnswer(text)
+ if (answer !== null) {
+ const offered = cashoutQuestion.choices
+ const named = 'currency' in answer ? answer.currency : answer.unsupported
+ if (offered.some((choice) => choice.code === named)) {
+ answerCashoutQuestion(named, text)
+ } else {
+ setCashoutQuestion({
+ ...cashoutQuestion,
+ retry: { said: text, reply: unsupportedCurrencyReply(named, offered) },
+ })
+ }
+ return
+ }
+ }
+ setCashoutQuestion(null)
+
+ cashout.dismiss()
+ if (chainCapabilities(slug) === undefined) chainAction.reset()
+
+ // Solana, Sui and NEAR have their own path: the race and the swap routes
+ // below are Stellar's. Anything that isn't an instruction, such as a
+ // question, still goes to the orchestrator.
+ const capabilities = chainCapabilities(slug)
+ if (capabilities !== undefined) {
+ const action = parseChainAction(text)
+ if (action !== null) {
+ // The thread only renders once there is a message, so without this the
+ // card below would be built and never seen.
+ setMessage(text)
+ chainAction.prepare(action, text)
+ return
+ }
+ if (looksLikeChainAction(text)) {
+ setMessage(text)
+ chainAction.refuse(chainHelp(descriptor.name, capabilities), text)
+ return
+ }
+ chainAction.reset()
+ }
+
// An answer to a question already asked.
if (clarifying !== null) {
void clarify(text, clarifying.turns)
@@ -1431,6 +1625,13 @@ export function IntentChat(): JSX.Element {
// conversation the next message belongs to.
dialogue.current = []
+ // The model wrote out a cash-out, perhaps from words no parser here would read.
+ const writtenOut = routeCashout(parseCashout(answer.instruction), slug, isMainnet())
+ if (writtenOut.kind !== 'pass') {
+ startCashout(writtenOut, turns[0]?.content ?? text)
+ return
+ }
+
// Tokens the server proved for this request, so the page can trade them
// without a hand-written entry.
for (const asset of answer.read.liveAssets) {
@@ -1492,12 +1693,16 @@ export function IntentChat(): JSX.Element {
setNotes([])
setClarifying(null)
setClarifyBusy(false)
+ setCashoutQuestion(null)
setBrief([])
setStrategy({})
}
function handleReset(): void {
- setMessage(null)
+ // The thread stays while a cash-out is under way: it carries the warning not to resend.
+ setMessage(cashout.locked ? message : null)
+ cashout.dismiss()
+ setCashoutQuestion(null)
setParsed(null)
setExecutingKey(null)
setPlacedId(null)
@@ -1857,9 +2062,12 @@ export function IntentChat(): JSX.Element {
showing it lets the user see the conversion before writing the
sentence rather than after signing it. Hidden on narrow screens,
where the centred label and the actions already compete. */}
-
-
-
+
+ {slug === 'stellar' ?
:
}
+
{/* Past conversations. The chat keeps nothing across a reload on its
own, so without this the agents' reasoning is lost the moment the
page refreshes or a second intent is composed. */}
@@ -2155,6 +2363,14 @@ export function IntentChat(): JSX.Element {
)
)
: null}
+ {cashoutQuestion !== null ? (
+
+ answerCashoutQuestion(choice.code, `${choice.name} (${choice.code})`)
+ }
+ />
+ ) : null}
{clarifyBusy ? (
Thinking…
) : null}
@@ -2344,6 +2560,12 @@ export function IntentChat(): JSX.Element {
one signature — no sequence to explain. */}
{supply.phase !== 'idle' ? : null}
+ {chainAction.phase !== 'idle' ? : null}
+
+ {cashout.session !== null ? (
+
+ ) : null}
+
{/* A leveraged position. One transaction, one signature, and a
liquidation price to read before it. */}
{perp.phase !== 'idle' ? : null}
diff --git a/apps/dapp/components/intents/native-coin-ticker.tsx b/apps/dapp/components/intents/native-coin-ticker.tsx
new file mode 100644
index 00000000..084c1b19
--- /dev/null
+++ b/apps/dapp/components/intents/native-coin-ticker.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+
+import { formatCoinUsd, type NativeCoinPrice } from '../../lib/prices/native-coin'
+import { useChain } from '../../providers/chain-provider'
+import { CoinStat } from './coin-stat'
+
+async function loadPrice(chain: string): Promise {
+ const res = await fetch(`/api/prices/native?chain=${encodeURIComponent(chain)}`)
+ if (!res.ok) throw new Error('could not read the price')
+ const body = (await res.json()) as { price?: NativeCoinPrice | null }
+ return body.price ?? null
+}
+
+/** The live price of the coin of the chain this page is on. */
+export function NativeCoinTicker(): JSX.Element | null {
+ const { slug, descriptor } = useChain()
+ const { symbol } = descriptor.nativeCurrency
+ const { data: price, isPending } = useQuery({
+ queryKey: ['native-coin-price', slug],
+ queryFn: () => loadPrice(slug),
+ refetchInterval: 60_000,
+ staleTime: 30_000,
+ })
+
+ if (isPending) return null
+
+ if (price == null) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/dapp/components/intents/price-ticker.tsx b/apps/dapp/components/intents/price-ticker.tsx
index 87ae0416..d45166fb 100644
--- a/apps/dapp/components/intents/price-ticker.tsx
+++ b/apps/dapp/components/intents/price-ticker.tsx
@@ -4,6 +4,7 @@ import { activeNetwork } from '@intent/config'
import { useQuery } from '@tanstack/react-query'
import type { MarketPrice } from '../../lib/swap/price-types'
+import { CoinStat } from './coin-stat'
/**
* What XLM trades for, on the network this app actually executes against.
@@ -63,35 +64,34 @@ export function PriceTicker({
? 'XLM on the Stellar mainnet order book. Reflector could not be read, so this single venue stands in for it.'
: 'Neither Reflector nor the mainnet order book could be read, so this is a fallback figure rather than a live quote.'
- return (
-
- {testnet != null ? (
-
-
- XLM ${testnet.usd.toFixed(4)}
- {here}
-
- ) : (
-
-
- {here} book unavailable
-
- )}
+ const bookTitle =
+ testnet != null
+ ? `XLM mid price from the Stellar ${here} order book — the venue this app signs against, so this is the rate your swaps actually fill near.`
+ : `The ${here} XLM/USDC order book could not be read, or has no offers resting on it right now.`
- {/* The real market, kept visible but subordinate. Dollar amounts typed
- into an intent are sized against this, and the agents compare routes
- with it, so hiding it would make those numbers unaccountable. */}
- {worth !== undefined ? (
-
- · {worthLabel} ${worth.usd.toFixed(4)}
-
- ) : null}
-
+ return (
+
+ {here === 'mainnet'
+ ? testnet != null
+ ? 'XLM price'
+ : 'Price unavailable'
+ : testnet != null
+ ? `XLM on ${here}`
+ : `${here} book unavailable`}
+ {/* The real market, kept visible but subordinate. Dollar amounts typed
+ into an intent are sized against this, and the agents compare routes
+ with it, so hiding it would make those numbers unaccountable. */}
+ {worth !== undefined ? (
+
+ , {worthLabel} ${worth.usd.toFixed(4)}
+
+ ) : null}
+ >
+ }
+ />
)
}
diff --git a/apps/dapp/components/intents/swap-confirm.tsx b/apps/dapp/components/intents/swap-confirm.tsx
index 83c2c1d2..c1a321ff 100644
--- a/apps/dapp/components/intents/swap-confirm.tsx
+++ b/apps/dapp/components/intents/swap-confirm.tsx
@@ -1,5 +1,6 @@
'use client'
+import { isMainnet } from '@intent/config'
import { ChainMark, cn } from '@intent/ui'
import {
ArrowDown,
@@ -539,8 +540,9 @@ export function SwapConfirm({
{distorted ? (
- This testnet venue's rate differs sharply from the real market, so the amount you
- receive is not what this trade would return on mainnet.
+ {isMainnet()
+ ? 'This venue’s rate differs sharply from the wider market. Check the amount you receive before signing.'
+ : 'This testnet venue’s rate differs sharply from the real market, so the amount you receive is not what this trade would return on mainnet.'}
) : null}
diff --git a/apps/dapp/components/layout/chain-switcher.tsx b/apps/dapp/components/layout/chain-switcher.tsx
index 797afe39..0d08f90c 100644
--- a/apps/dapp/components/layout/chain-switcher.tsx
+++ b/apps/dapp/components/layout/chain-switcher.tsx
@@ -2,7 +2,7 @@
import {
CHAIN_DESCRIPTORS,
- CHAIN_ORDER,
+ CONNECT_CHAINS,
activeNetwork,
chainSegment,
enabledNetworks,
@@ -11,36 +11,21 @@ import {
type StellarNetworkName,
} from '@intent/config'
import type { ChainSlug } from '@intent/types'
-import { ChainMark, type ChainLogoId } from '@intent/ui'
+import { ChainMark } from '@intent/ui'
import { cn } from '@intent/ui'
import { ArrowUpRight, Check, ChevronDown } from 'lucide-react'
-import { usePathname, useRouter } from 'next/navigation'
+import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { useGoToChain } from '../../hooks/use-go-to-chain'
+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 { useChain } from '../../providers/chain-provider'
-/**
- * The network as a short tag beside the chain's name. Testnet is amber so it
- * cannot be mistaken for the network that moves real money.
- */
-function NetworkTag({ label, className }: { label: string; className?: string }): JSX.Element {
- const test = /test/i.test(label)
- const short = label.replace(/^(stellar|arc)\s+/i, '')
- return (
-
- {short}
-
- )
-}
-
/**
* Switches chain, and Stellar network, while staying on the same screen.
*
@@ -54,12 +39,6 @@ function NetworkTag({ label, className }: { label: string; className?: string })
* what the wallet session, the cached data and every API call mean, and none
* of it should carry across from the other network.
*/
-/** Planned, not built. Listed so the roadmap is visible; never selectable. */
-const COMING_SOON: { id: ChainLogoId; name: string }[] = [
- { id: 'solana', name: 'Solana' },
- { id: 'avalanche', name: 'Avalanche' },
-]
-
interface Entry {
key: string
segment: string
@@ -72,10 +51,10 @@ interface Entry {
/** One entry per place a user can be, when several networks are served. */
function entriesFor(): Entry[] {
const out: Entry[] = []
- for (const option of CHAIN_ORDER) {
- if (option === 'arc') {
- const d = CHAIN_DESCRIPTORS.arc
- out.push({ key: 'arc', segment: 'arc', slug: 'arc', name: d.name, label: d.networkLabel })
+ for (const option of CONNECT_CHAINS) {
+ if (option !== 'stellar') {
+ const d = CHAIN_DESCRIPTORS[option]
+ out.push({ key: option, segment: option, slug: option, name: d.name, label: d.networkLabel })
continue
}
for (const network of enabledNetworks()) {
@@ -100,9 +79,10 @@ export function ChainSwitcher({
caption?: ReactNode
} = {}): JSX.Element {
const { slug, network, descriptor } = useChain()
- const router = useRouter()
const pathname = usePathname()
+ const goToChain = useGoToChain()
const [open, setOpen] = useState(false)
+ const [connecting, setConnecting] = useState(undefined)
const rootRef = useRef(null)
const multi = isMultiNetwork()
const { isConnected } = useWallet()
@@ -131,24 +111,23 @@ export function ChainSwitcher({
}
}, [open])
- /** The same screen under another segment; everything deeper is chain-independent. */
- function pathUnder(segment: string): string {
- const rest = pathname.split('/').slice(2).join('/')
- return `/${segment}${rest === '' ? '/intents' : `/${rest}`}`
- }
-
function switchTo(entry: Entry): void {
setOpen(false)
const active = entry.slug === slug && entry.network === network
if (active) return
+ if (entry.slug !== 'stellar') {
+ switchChain(entry.slug)
+ return
+ }
// A full load, so nothing from the other network is carried into this one.
- window.location.assign(pathUnder(entry.segment))
+ window.location.assign(pathUnderChain(pathname, entry.segment))
}
- function switchChain(next: string): void {
+ function switchChain(next: ChainSlug): void {
setOpen(false)
if (next === slug) return
- router.push(pathUnder(next))
+ if (connectorFor(next).isConnected()) goToChain(next)
+ else setConnecting(next)
}
const rowClass = (active: boolean): string =>
@@ -179,8 +158,7 @@ export function ChainSwitcher({
title={isConnected ? 'Wallet connected' : 'No wallet connected'}
/>
- {descriptor.name}
-
+ {chainTitle(descriptor.name, descriptor.networkLabel)}
-
- {entry.name}
-
-
+ {chainTitle(entry.name, entry.label)}
{entry.slug === slug && entry.network === network ? (
) : null}
))
- : CHAIN_ORDER.map((option) => {
+ : CONNECT_CHAINS.map((option) => {
const d = CHAIN_DESCRIPTORS[option]
return (
-
- {d.name}
-
-
+ {chainTitle(d.name, d.networkLabel)}
{option === slug ? : null}
)
@@ -248,45 +220,22 @@ export function ChainSwitcher({
className="hover:bg-muted/60 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors"
>
-
- Stellar
-
-
+
{chainTitle('Stellar', other.label)}
) : null}
- {!multi && other.href === undefined ? (
-
-
-
- Stellar
- {other.label} · not set up here
-
-
- ) : null}
-
-
- Coming soon
-
- {COMING_SOON.map((c) => (
-
-
- {c.name}
-
- ))}
) : null}
+ {connecting !== undefined ? (
+ {
+ setConnecting(undefined)
+ goToChain(next)
+ }}
+ onClose={() => setConnecting(undefined)}
+ />
+ ) : null}
)
}
diff --git a/apps/dapp/components/layout/chain-wallet-menu.tsx b/apps/dapp/components/layout/chain-wallet-menu.tsx
new file mode 100644
index 00000000..15deca77
--- /dev/null
+++ b/apps/dapp/components/layout/chain-wallet-menu.tsx
@@ -0,0 +1,182 @@
+'use client'
+
+import { Button, cn } from '@intent/ui'
+import { Check, ChevronDown, Copy, ExternalLink, LogOut, Wallet } from 'lucide-react'
+import Image from 'next/image'
+import { useEffect, useRef, useState } from 'react'
+
+import { useWallet } from '../../hooks/use-wallet'
+import { chainTitle } from '../../lib/chain-title'
+import { useChain } from '../../providers/chain-provider'
+
+function short(address: string): string {
+ return address.length <= 16 ? address : `${address.slice(0, 6)}…${address.slice(-4)}`
+}
+
+function amount(value: string): string {
+ return Number(value).toLocaleString(undefined, { maximumFractionDigits: 5 })
+}
+
+function WalletBadge({
+ icon,
+ className,
+}: {
+ icon: string | undefined
+ className: string
+}): JSX.Element {
+ return icon !== undefined ? (
+
+ ) : (
+
+ )
+}
+
+/** The connected wallet on a chain without Stellar's holdings and activity panel. */
+export function ChainWalletMenu({
+ onDisconnect,
+}: {
+ onDisconnect: () => void
+}): JSX.Element | null {
+ const { adapter, descriptor } = useChain()
+ const { address, balance, balanceSymbol, walletIcon, walletName, isWrongNetwork, switchNetwork } =
+ useWallet()
+ const [open, setOpen] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const rootRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(e: PointerEvent): void {
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
+ }
+ function onKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('pointerdown', onPointerDown)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('pointerdown', onPointerDown)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open])
+
+ if (address === undefined) return null
+
+ function copy(value: string): void {
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1500)
+ })
+ }
+
+ return (
+
+
setOpen((v) => !v)}
+ aria-haspopup="dialog"
+ aria-expanded={open}
+ aria-label={`Wallet ${short(address)}`}
+ className="border-border bg-card hover:bg-muted/60 focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-md border pl-1.5 pr-2 transition-colors focus-visible:outline-none focus-visible:ring-2"
+ >
+
+
+
+
+ {short(address)}
+
+
+
+ {open ? (
+
+
+
+
+
+ {short(address)}
+
+
+ {walletName ?? 'Wallet'} on {chainTitle(descriptor.name, descriptor.networkLabel)}
+
+
+
copy(address)}
+ aria-label={copied ? 'Address copied' : 'Copy address'}
+ className="text-muted-foreground hover:text-foreground hover:bg-muted/60 flex h-8 w-8 items-center justify-center rounded-md transition-colors"
+ >
+ {copied ? : }
+
+
+
+
+
+
+ {isWrongNetwork ? (
+
+
+ Your wallet is on another network.
+
+
+ Switch to {descriptor.name}
+
+
+ ) : (
+
+
Balance
+
+ {balance !== undefined ? amount(balance) : '—'}{' '}
+ {balanceSymbol}
+
+
+ )}
+
+
+ {
+ setOpen(false)
+ onDisconnect()
+ }}
+ className="w-full gap-2 text-sm"
+ >
+
+ Disconnect
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/apps/dapp/components/layout/connect-wallet.tsx b/apps/dapp/components/layout/connect-wallet.tsx
index e9caf711..afc6491a 100644
--- a/apps/dapp/components/layout/connect-wallet.tsx
+++ b/apps/dapp/components/layout/connect-wallet.tsx
@@ -1,104 +1,38 @@
'use client'
-import { ConnectButton } from '@rainbow-me/rainbowkit'
-import { Button } from '@intent/ui'
-import { Wallet } from 'lucide-react'
-
import { useChain } from '../../providers/chain-provider'
import { useWallet } from '../../hooks/use-wallet'
+import { forgetChain } from '../../lib/wallet/chain-cookie'
+import { ChainWalletMenu } from './chain-wallet-menu'
import { StellarWalletMenu } from './stellar-wallet-menu'
/**
- * Connect control for the active chain.
- *
- * RainbowKit only knows about EVM wallets, so it cannot render Freighter — the
- * Stellar side needs its own button rather than a configuration of the same one.
+ * The connected wallet in the header. Connecting happens on the connect page,
+ * which is the only way into the app, so a disconnected wallet has nothing to
+ * show here. Disconnecting goes back to that page.
*/
-export function ConnectWallet(): JSX.Element {
- const { descriptor } = useChain()
+export function ConnectWallet(): JSX.Element | null {
+ const { slug } = useChain()
+ const { address, isConnected, balance, balanceSymbol, walletIcon, disconnect } = useWallet()
- if (descriptor.family === 'evm') {
- return (
-
- {({ account, chain, mounted, authenticationStatus, openConnectModal }) => {
- const ready = mounted && authenticationStatus !== 'loading'
- const connected = ready && account !== undefined && chain !== undefined
- // Not connected: the same button the Stellar side shows, so the
- // control does not change shape with the chain. Connected, and
- // while it is not yet known, RainbowKit's own control takes over.
- if (ready && !connected) return
- return (
-
-
-
- )
- }}
-
- )
+ function leave(): void {
+ forgetChain()
+ disconnect()
}
- return
-}
+ if (!isConnected || address === undefined) return null
-function StellarConnect(): JSX.Element {
- const {
- address,
- isConnected,
- isConnecting,
- balance,
- balanceSymbol,
- walletIcon,
- error,
- connect,
- disconnect,
- } = useWallet()
-
- if (isConnected && address !== undefined) {
+ if (slug === 'stellar') {
return (
)
}
- return (
-
- {error !== undefined ? (
-
- {error}
-
- ) : null}
-
-
- )
-}
-
-/** The one disconnected button, whatever the chain. */
-function ConnectButtonView({
- onClick,
- busy = false,
-}: {
- onClick: () => void
- busy?: boolean
-}): JSX.Element {
- return (
-
-
- {busy ? 'Connecting…' : 'Connect wallet'}
-
- )
+ return
}
diff --git a/apps/dapp/components/layout/network-status.tsx b/apps/dapp/components/layout/network-status.tsx
index 2a013191..c633664c 100644
--- a/apps/dapp/components/layout/network-status.tsx
+++ b/apps/dapp/components/layout/network-status.tsx
@@ -104,7 +104,7 @@ export function NetworkStatus({
{FAUCET_LABEL[slug]}
)
- ) : isMainnet() ? (
+ ) : isMainnet() || (slug !== 'stellar' && slug !== 'arc') ? (
tradeCapUsd !== undefined && slug === 'stellar' ? (
Trades up to ${tradeCapUsd}
diff --git a/apps/dapp/components/layout/sidebar.tsx b/apps/dapp/components/layout/sidebar.tsx
index 058ced0f..ce9f0ef9 100644
--- a/apps/dapp/components/layout/sidebar.tsx
+++ b/apps/dapp/components/layout/sidebar.tsx
@@ -1,10 +1,13 @@
'use client'
import { ChainMark, cn } from '@intent/ui'
-import { BarChart3, Boxes, History, Sparkles, Vault, Wallet } from 'lucide-react'
-import Link from 'next/link'
+import { BarChart3, Boxes, History, Loader2, Sparkles, Vault, Wallet } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import Link, { useLinkStatus } from 'next/link'
import { usePathname } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { createNavGuard } from '../../lib/nav-guard'
import { useChain } from '../../providers/chain-provider'
import { MobileAppCard } from './mobile-app-card'
import { BrandMark } from './brand-mark'
@@ -18,6 +21,16 @@ const nav = [
{ href: '/vault', label: 'Vault', icon: Vault, soon: true },
] as const
+/** A spinner while its link loads, so a click that is working looks like it. */
+function NavIcon({ icon: Icon }: { icon: LucideIcon }): JSX.Element {
+ const { pending } = useLinkStatus()
+ return pending ? (
+
+ ) : (
+
+ )
+}
+
export function Sidebar({
onNavigate,
className,
@@ -28,6 +41,9 @@ export function Sidebar({
} = {}): JSX.Element {
const pathname = usePathname()
const { slug, segment, descriptor } = useChain()
+ const [guard] = useState(() => createNavGuard())
+
+ useEffect(() => guard.settle(), [guard, pathname])
return (
onNavigate?.()}
+ onClick={(e) => {
+ if (pathname !== href && !guard.follow(href)) {
+ e.preventDefault()
+ return
+ }
+ // Closes the drawer on mobile. Without it the menu stays open
+ // over the page the user just asked for.
+ onNavigate?.()
+ }}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active
@@ -60,7 +82,7 @@ export function Sidebar({
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
)}
>
-
+
{label}
{soon ? (
{short(address)}
-
{descriptor.networkLabel}
+
+ {chainTitle(descriptor.name, descriptor.networkLabel)}
+
-
- Native balance
- {descriptor.networkLabel}
-
+ Native balance
{balance === undefined ? (
diff --git a/apps/dapp/e2e/chain-action.spec.ts b/apps/dapp/e2e/chain-action.spec.ts
new file mode 100644
index 00000000..e400f2e5
--- /dev/null
+++ b/apps/dapp/e2e/chain-action.spec.ts
@@ -0,0 +1,202 @@
+import { expect, test, type Page } from '@playwright/test'
+
+/**
+ * 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: [] } })
+
+const ADDRESS = 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg'
+const UNSIGNED = btoa('unsigned-transaction')
+const SIGNED = btoa('signed-transaction')
+
+async function connectFakeSolanaWallet(page: Page, declines = false): Promise {
+ await page.addInitScript(
+ ({ address, signed, declines }) => {
+ const account = {
+ address,
+ publicKey: new Uint8Array(32),
+ chains: ['solana:mainnet'],
+ features: [],
+ }
+ const wallet = {
+ version: '1.0.0',
+ name: 'Fake Solana',
+ icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=',
+ chains: ['solana:mainnet'],
+ accounts: [account],
+ features: {
+ 'standard:connect': { version: '1.0.0', connect: async () => ({ accounts: [account] }) },
+ 'standard:events': { version: '1.0.0', on: () => () => undefined },
+ 'solana:signTransaction': {
+ version: '1.0.0',
+ supportedTransactionVersions: ['legacy', 0],
+ signTransaction: async (input: { transaction: Uint8Array }) => {
+ if (declines) throw new Error('User rejected the request.')
+ ;(window as unknown as { signedInput: string }).signedInput = String.fromCharCode(
+ ...input.transaction
+ )
+ return [
+ {
+ signedTransaction: Uint8Array.from(atob(signed), (c) => c.charCodeAt(0)),
+ },
+ ]
+ },
+ },
+ },
+ }
+ const register = (api: { register: (w: unknown) => void }): void => {
+ api.register(wallet)
+ }
+ window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register }))
+ window.addEventListener('wallet-standard:app-ready', (e) => {
+ register((e as CustomEvent).detail)
+ })
+ },
+ { address: ADDRESS, signed: SIGNED, declines }
+ )
+
+ await page.goto('/solana/intents')
+ await page.getByRole('button', { name: 'Connect wallet' }).click()
+ const steps = page.locator('[data-connect-steps]')
+ await steps.getByRole('button', { name: 'Solana' }).click()
+ await steps.getByRole('button', { name: 'Fake Solana' }).click()
+ await expect(page.getByRole('textbox', { name: 'Describe your intent' })).toBeVisible()
+}
+
+async function say(page: Page, text: string): Promise {
+ await page.getByRole('textbox', { name: 'Describe your intent' }).fill(text)
+ await page.getByRole('button', { name: 'Send intent' }).click()
+}
+
+async function stubPrepare(page: Page): Promise<() => unknown> {
+ let body: unknown
+ await page.route('**/api/chain/solana/prepare', async (route) => {
+ body = route.request().postDataJSON()
+ await route.fulfill({
+ json: {
+ prepared: {
+ venueId: 'jupiter',
+ venueName: 'Jupiter',
+ kind: 'swap',
+ summary: 'Swap 1 USDC for SOL on Jupiter.',
+ pay: { amount: '1', symbol: 'USDC' },
+ receive: { amount: '0.0061', symbol: 'SOL', approximate: true },
+ notes: ['The price can move before this lands.'],
+ signing: { family: 'solana', transaction: UNSIGNED },
+ context: { requestId: 'r1' },
+ },
+ },
+ })
+ })
+ return () => body
+}
+
+test('a typed swap reaches review, signs, submits and reports the result', async ({ page }) => {
+ await connectFakeSolanaWallet(page)
+ const prepared = await stubPrepare(page)
+ let submitted: unknown
+ await page.route('**/api/chain/solana/submit', async (route) => {
+ submitted = route.request().postDataJSON()
+ await route.fulfill({
+ json: { ok: true, hash: 'abc123def456', explorerUrl: 'https://solscan.io/tx/abc123def456' },
+ })
+ })
+
+ await say(page, 'Swap 1 USDC to SOL')
+
+ await expect(page.getByText('Swap 1 USDC for SOL on Jupiter.')).toBeVisible()
+ await expect(page.getByText('The price can move before this lands.')).toBeVisible()
+ expect(prepared()).toEqual({
+ account: ADDRESS,
+ request: { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SOL' },
+ })
+
+ await page.getByRole('button', { name: 'Sign and send' }).click()
+
+ await expect(page.getByRole('link', { name: /abc123/ })).toHaveAttribute(
+ 'href',
+ 'https://solscan.io/tx/abc123def456'
+ )
+ // What the wallet was given is exactly what the server prepared.
+ expect(
+ await page.evaluate(() => (window as unknown as { signedInput: string }).signedInput)
+ ).toBe('unsigned-transaction')
+ // And what the server gets back is exactly what the wallet signed.
+ expect(submitted).toEqual({
+ account: ADDRESS,
+ venueId: 'jupiter',
+ signed: { family: 'solana', signedTransaction: SIGNED },
+ context: { requestId: 'r1' },
+ })
+})
+
+test('declining in the wallet goes back to review, with nothing sent', async ({ page }) => {
+ await connectFakeSolanaWallet(page, true)
+ await stubPrepare(page)
+ let sent = false
+ await page.route('**/api/chain/solana/submit', async (route) => {
+ sent = true
+ await route.abort()
+ })
+
+ await say(page, 'Swap 1 USDC to SOL')
+ await page.getByRole('button', { name: 'Sign and send' }).click()
+
+ await expect(page.getByRole('button', { name: 'Sign and send' })).toBeEnabled()
+ await expect(page.getByText('User rejected')).toHaveCount(0)
+ expect(sent).toBe(false)
+})
+
+test('a transaction with no outcome yet says so, and is not shown as a failure', async ({
+ page,
+}) => {
+ await connectFakeSolanaWallet(page)
+ await stubPrepare(page)
+ await page.route('**/api/chain/solana/submit', (route) =>
+ route.fulfill({
+ status: 202,
+ json: { ok: false, pending: true, error: "Still processing. Don't send it again." },
+ })
+ )
+
+ await say(page, 'Swap 1 USDC to SOL')
+ await page.getByRole('button', { name: 'Sign and send' }).click()
+
+ await expect(page.getByRole('status').filter({ hasText: 'Still processing' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Done' })).toBeVisible()
+})
+
+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.' } })
+ )
+ await say(page, 'Swap 9000 USDC to SOL')
+ await expect(page.getByRole('alert').filter({ hasText: 'caps each trade' })).toBeVisible()
+})
+
+test('an instruction it cannot read gets told what works, and nothing is built', async ({
+ page,
+}) => {
+ await connectFakeSolanaWallet(page)
+ let asked = false
+ await page.route('**/api/chain/solana/prepare', async (route) => {
+ asked = true
+ await route.abort()
+ })
+ await say(page, 'swap all my USDC')
+ await expect(page.getByRole('alert').filter({ hasText: 'swap 10 USDC to SOL' })).toBeVisible()
+ expect(asked).toBe(false)
+})
+
+test('the apps page shows which apps run and which are only listed', async ({ page }) => {
+ await connectFakeSolanaWallet(page)
+ await page.getByRole('link', { name: 'Apps' }).first().click()
+ await expect(page).toHaveURL(/\/solana\/apps$/, { timeout: 45_000 })
+ await expect(page.getByText('Jupiter', { exact: true }).first()).toBeVisible()
+ await expect(page.getByText('Kamino', { exact: false }).first()).toBeVisible()
+ await expect(page.getByText('Works from chat').first()).toBeVisible()
+ await expect(page.getByText('Agents trade here')).toHaveCount(0)
+})
diff --git a/apps/dapp/e2e/chain-switcher.spec.ts b/apps/dapp/e2e/chain-switcher.spec.ts
index effe0173..7d327be6 100644
--- a/apps/dapp/e2e/chain-switcher.spec.ts
+++ b/apps/dapp/e2e/chain-switcher.spec.ts
@@ -1,40 +1,33 @@
import { expect, test } from '@playwright/test'
/**
- * The switcher lists the two chains the app runs on and the two it plans to,
- * and the planned ones are visibly not selectable.
+ * The switcher lists the chains a person can connect to, and Arc is not one of
+ * them.
*
- * Items are found by accessible name ("Arc testnet", "Solana, coming
- * soon"): the name is what a screen reader gets, and the raw text content
- * runs the two spans together with no space to anchor on.
+ * Items are found by accessible name ("Solana mainnet"): the name is what a
+ * screen reader gets, and the raw text content runs the two spans together with
+ * no space to anchor on.
*/
-test('the chain switcher lists arc and stellar, with solana and avalanche coming soon', async ({
- page,
-}) => {
- await page.goto('/arc/intents')
+test('the chain switcher lists the six connectable chains, and not Arc', async ({ page }) => {
+ await page.goto('/stellar/intents')
await page.getByRole('button', { name: /^Chain: / }).click()
const menu = page.getByRole('menu', { name: 'Choose a chain' })
await expect(menu).toBeVisible()
- const arc = menu.getByRole('menuitem', { name: /^Arc\b/ })
- await expect(arc).toHaveCount(1)
- await expect(arc).toBeEnabled()
+ await expect(menu.getByRole('menuitem', { name: /^Arc\b/ })).toHaveCount(0)
- // Stellar appears for the network this server runs on, and again for the
- // other network: a link when another deployment is configured, otherwise a
- // disabled row that says it is not set up here.
+ // Stellar appears for the network this server runs on. The other network is
+ // a link only when another deployment is configured, and this run has none.
const active = menu.getByRole('menuitem', { name: /^Stellar testnet$/ })
await expect(active).toHaveCount(1)
await expect(active).toBeEnabled()
- const other = menu.getByRole('menuitem', { name: /^Stellar mainnet/ })
- await expect(other).toHaveCount(1)
- await expect(other).toHaveAttribute('aria-disabled', 'true')
+ await expect(menu.getByRole('menuitem', { name: /^Stellar mainnet/ })).toHaveCount(0)
- for (const chain of ['Solana', 'Avalanche']) {
- const item = menu.getByRole('menuitem', { name: new RegExp(`^${chain}\\b`) })
+ for (const chain of ['Solana', 'Avalanche', 'Sui', 'Monad', 'NEAR']) {
+ const item = menu.getByRole('menuitem', { name: `${chain} mainnet` })
await expect(item).toHaveCount(1)
- await expect(item).toHaveAccessibleName(/coming soon$/)
- await expect(item).toHaveAttribute('aria-disabled', 'true')
+ await expect(item).toBeEnabled()
+ await expect(item).not.toHaveAttribute('aria-disabled', 'true')
}
})
diff --git a/apps/dapp/e2e/connect-gate.spec.ts b/apps/dapp/e2e/connect-gate.spec.ts
new file mode 100644
index 00000000..b92f0667
--- /dev/null
+++ b/apps/dapp/e2e/connect-gate.spec.ts
@@ -0,0 +1,294 @@
+import { expect, test, type Page } from '@playwright/test'
+
+/**
+ * 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: [] } })
+
+const SOLANA_ADDRESS = 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg'
+const SUI_ADDRESS = `0x${'ab'.repeat(32)}`
+const EVM_ADDRESS = '0x52908400098527886E0F7030069857D2E4169EE7'
+
+async function installFakeWallets(page: Page): Promise {
+ await page.addInitScript(
+ ({ solana, sui, evm }) => {
+ const icon =
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='
+
+ function standardWallet(name: string, chain: string, address: string): unknown {
+ const account = { address, publicKey: new Uint8Array(32), chains: [chain], features: [] }
+ return {
+ version: '1.0.0',
+ name,
+ icon,
+ chains: [chain],
+ accounts: [account],
+ features: {
+ 'standard:connect': {
+ version: '1.0.0',
+ connect: async () => {
+ if (window.localStorage.getItem('fake-wallet-declines') === '1') {
+ throw new Error('User rejected the request.')
+ }
+ return { accounts: [account] }
+ },
+ },
+ 'standard:disconnect': { version: '1.0.0', disconnect: async () => undefined },
+ 'standard:events': { version: '1.0.0', on: () => () => undefined },
+ },
+ }
+ }
+
+ const wallets = [
+ standardWallet('Fake Solana', 'solana:mainnet', solana),
+ standardWallet('Fake Sui', 'sui:mainnet', sui),
+ ]
+ for (const wallet of wallets) {
+ const register = (api: { register: (w: unknown) => void }): void => {
+ api.register(wallet)
+ }
+ window.dispatchEvent(
+ new CustomEvent('wallet-standard:register-wallet', { detail: register })
+ )
+ window.addEventListener('wallet-standard:app-ready', (e) => {
+ register((e as CustomEvent).detail)
+ })
+ }
+
+ let chainId = '0x1'
+ const provider = {
+ on: () => undefined,
+ removeListener: () => undefined,
+ request: async ({ method, params }: { method: string; params?: { chainId: string }[] }) => {
+ switch (method) {
+ // Like a real wallet: no accounts until the site has been approved.
+ case 'eth_requestAccounts':
+ window.localStorage.setItem('fake-evm-approved', '1')
+ return [evm]
+ case 'eth_accounts':
+ return window.localStorage.getItem('fake-evm-approved') === '1' ? [evm] : []
+ case 'eth_chainId':
+ return chainId
+ case 'wallet_switchEthereumChain':
+ chainId = params?.[0]?.chainId ?? chainId
+ return null
+ default:
+ return null
+ }
+ },
+ }
+ const announce = (): void => {
+ window.dispatchEvent(
+ new CustomEvent('eip6963:announceProvider', {
+ detail: {
+ info: { uuid: 'fake-evm', name: 'Fake EVM', icon, rdns: 'xyz.fake.evm' },
+ provider,
+ },
+ })
+ )
+ }
+ window.addEventListener('eip6963:requestProvider', announce)
+ announce()
+ },
+ { solana: SOLANA_ADDRESS, sui: SUI_ADDRESS, evm: EVM_ADDRESS }
+ )
+}
+
+const connectButton = (page: Page) => page.getByRole('button', { name: 'Connect wallet' })
+const dialog = (page: Page) => page.locator('[data-connect-steps]')
+const appNav = (page: Page) => page.getByRole('link', { name: 'History' })
+
+test.beforeEach(async ({ page }) => {
+ await installFakeWallets(page)
+})
+
+test('the root shows the connect page, with no chain in the address', async ({ page }) => {
+ await page.goto('/')
+ await expect(connectButton(page)).toBeVisible()
+ await expect(appNav(page)).toHaveCount(0)
+ expect(new URL(page.url()).pathname).toBe('/')
+})
+
+test('a chain screen without a wallet goes to the connect page', async ({ page }) => {
+ await page.goto('/near/intents')
+ await expect(connectButton(page)).toBeVisible()
+ await expect(appNav(page)).toHaveCount(0)
+ expect(new URL(page.url()).pathname).toBe('/')
+})
+
+test('picking a chain puts it in the address, and every screen keeps it', async ({ page }) => {
+ await page.goto('/')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Solana' }).click()
+ await expect(page).toHaveURL(/\/solana\/intents$/)
+
+ await page.getByRole('link', { name: 'Agents' }).first().click()
+ await expect(page).toHaveURL(/\/solana\/agents$/, { timeout: 45_000 })
+})
+
+test('the picker offers the six chains, and not Arc', async ({ page }) => {
+ await page.goto('/stellar/intents')
+ 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: 'Arc' })).toHaveCount(0)
+})
+
+test('each chain lists its own wallets, ready ones first', async ({ page }) => {
+ await page.goto('/stellar/intents')
+ await connectButton(page).click()
+
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await expect(dialog(page).getByRole('button', { name: 'Fake Solana' })).toBeVisible()
+ await expect(dialog(page).getByRole('link', { name: /Phantom/ })).toHaveAttribute(
+ 'href',
+ 'https://phantom.com/download'
+ )
+ await expect(dialog(page).getByText('Fake Sui')).toHaveCount(0)
+
+ await page.getByRole('button', { name: 'Choose another chain' }).click()
+ await dialog(page).getByRole('button', { name: 'Sui' }).click()
+ await expect(dialog(page).getByRole('button', { name: 'Fake Sui' })).toBeVisible()
+ await expect(dialog(page).getByText('Fake Solana')).toHaveCount(0)
+
+ await page.getByRole('button', { name: 'Choose another chain' }).click()
+ await dialog(page).getByRole('button', { name: 'Avalanche' }).click()
+ await expect(dialog(page).getByRole('button', { name: 'Fake EVM' })).toBeVisible()
+
+ await page.getByRole('button', { name: 'Choose another chain' }).click()
+ await dialog(page).getByRole('button', { name: 'Stellar' }).click()
+ await expect(dialog(page).getByText('Freighter')).toBeVisible()
+})
+
+test('NEAR lists wallets from its manifest', async ({ page }) => {
+ await page.goto('/stellar/intents')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'NEAR' }).click()
+ await expect(dialog(page).getByRole('button', { name: /Meteor/ })).toBeVisible({
+ timeout: 20_000,
+ })
+})
+
+test('connecting opens the app on that chain, on the page that was asked for', async ({ page }) => {
+ await page.goto('/stellar/history')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Solana' }).click()
+
+ await expect(page).toHaveURL(/\/solana\/history$/)
+ await expect(appNav(page)).toBeVisible()
+ await expect(page.getByRole('button', { name: /^Wallet vines1/ })).toBeVisible()
+})
+
+test('a returning visitor goes straight back in', async ({ page }) => {
+ await page.goto('/sui/intents')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Sui' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Sui' }).click()
+ await expect(appNav(page)).toBeVisible()
+
+ await page.reload()
+ await expect(appNav(page)).toBeVisible()
+ await expect(connectButton(page)).toHaveCount(0)
+
+ await page.goto('/')
+ await expect(page).toHaveURL(/\/sui\/intents$/)
+ await expect(appNav(page)).toBeVisible()
+})
+
+test('disconnecting goes back to the connect page', async ({ page }) => {
+ await page.goto('/solana/intents')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Solana' }).click()
+ await page.getByRole('button', { name: /^Wallet vines1/ }).click()
+ await page.getByRole('button', { name: 'Disconnect' }).click()
+
+ await expect(connectButton(page)).toBeVisible()
+ await expect(appNav(page)).toHaveCount(0)
+ await expect(page).toHaveURL(/\/$/)
+
+ await page.reload()
+ await expect(connectButton(page)).toBeVisible()
+})
+
+test('declining in the wallet stays on the list, with no error', async ({ page }) => {
+ await page.addInitScript(() => window.localStorage.setItem('fake-wallet-declines', '1'))
+ await page.goto('/stellar/intents')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Solana' }).click()
+
+ await expect(dialog(page).getByRole('button', { name: 'Fake Solana' })).toBeEnabled()
+ await expect(dialog(page).getByRole('alert')).toHaveCount(0)
+ await expect(appNav(page)).toHaveCount(0)
+})
+
+test('an EVM wallet is asked onto the chain that was picked', async ({ page }) => {
+ await page.goto('/stellar/intents')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Avalanche' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake EVM' }).click()
+
+ await expect(page).toHaveURL(/\/avalanche\/intents$/)
+ await expect(appNav(page)).toBeVisible()
+ await expect(page.getByText('Wrong network')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: /^Wallet 0x5290/ })).toBeVisible()
+})
+
+test('the switcher asks for a wallet on a chain that has none, in place', async ({ page }) => {
+ await page.goto('/solana/intents')
+ await connectButton(page).click()
+ await dialog(page).getByRole('button', { name: 'Solana' }).click()
+ await dialog(page).getByRole('button', { name: 'Fake Solana' }).click()
+ await expect(appNav(page)).toBeVisible()
+
+ await page.getByRole('button', { name: /Switch chain/ }).click()
+ await page.getByRole('menuitem', { name: /Sui mainnet/ }).click()
+ await expect(dialog(page).getByRole('heading', { name: 'Connect to Sui' })).toBeVisible()
+ await expect(page).toHaveURL(/\/solana\/intents$/)
+
+ await dialog(page).getByRole('button', { name: 'Fake Sui' }).click()
+ await expect(page).toHaveURL(/\/sui\/intents$/)
+
+ await page.getByRole('button', { name: /Switch chain/ }).click()
+ await page.getByRole('menuitem', { name: /Solana mainnet/ }).click()
+ await expect(page).toHaveURL(/\/solana\/intents$/)
+ await expect(dialog(page)).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')
+ await connectButton(page).click()
+ await expect(dialog(page)).toBeInViewport({ ratio: 1 })
+ await expect(page.getByRole('complementary', { name: 'What Intent does' })).toHaveCount(0)
+})
+
+test('the slides move on by themselves, and can be paused and chosen', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 })
+ await page.goto('/stellar/intents')
+ const panel = page.getByRole('complementary', { name: 'What Intent does' })
+ await expect(panel.getByRole('heading', { name: 'Say what you want' })).toBeVisible()
+ await expect(panel.getByRole('heading', { name: 'Agents compete for your trade' })).toBeVisible({
+ timeout: 12_000,
+ })
+
+ await panel.getByRole('button', { name: 'Pause slides' }).click()
+ await panel.getByRole('button', { name: /^Show slide 4/ }).click()
+ await expect(panel.getByRole('heading', { name: 'Cash out to naira' })).toBeVisible()
+ await page.waitForTimeout(8_000)
+ await expect(panel.getByRole('heading', { name: 'Cash out to naira' })).toBeVisible()
+})
+
+test('going back from the chain step returns to the connect button', async ({ page }) => {
+ await page.goto('/stellar/intents')
+ await connectButton(page).click()
+ await page.getByRole('button', { name: 'Back', exact: true }).click()
+ await expect(connectButton(page)).toBeVisible()
+})
diff --git a/apps/dapp/e2e/home-redirect.spec.ts b/apps/dapp/e2e/home-redirect.spec.ts
index 9af1c412..f2a0f8b6 100644
--- a/apps/dapp/e2e/home-redirect.spec.ts
+++ b/apps/dapp/e2e/home-redirect.spec.ts
@@ -1,22 +1,13 @@
import { expect, test } from '@playwright/test'
/**
- * The bare root has no page of its own: middleware redirects it to the home
- * chain's intents page, and that page's composer is the first thing anyone sees.
- * This run serves one Stellar network, so home is the single `/stellar` address.
- * The two-network case, where home is Stellar mainnet, is in network-switch.spec.
+ * The bare root is the connect page, with no chain in the address: the chain goes
+ * in once one is picked. See connect-gate.spec for that part.
*/
-test('the root redirects to Stellar, the default chain, and the composer renders', async ({
- page,
-}) => {
+test('the root is the connect page, with no chain in the address', async ({ page }) => {
await page.goto('/')
-
- // The default chain is a decision, not an accident: a regression that sent
- // the root back to Arc would still be "a chain intents page".
- await expect(page).toHaveURL(/\/stellar\/intents$/)
- await expect(page.getByRole('heading', { name: 'Intents', exact: true })).toBeVisible()
- await expect(page.getByRole('textbox', { name: 'Describe your intent' })).toBeVisible()
- await expect(page.getByRole('button', { name: 'Send intent' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Connect wallet' })).toBeVisible()
+ expect(new URL(page.url()).pathname).toBe('/')
})
test('an old unprefixed address opens the same screen on the default chain', async ({ page }) => {
diff --git a/apps/dapp/e2e/network-switch.spec.ts b/apps/dapp/e2e/network-switch.spec.ts
index 2095b629..af8919bc 100644
--- a/apps/dapp/e2e/network-switch.spec.ts
+++ b/apps/dapp/e2e/network-switch.spec.ts
@@ -37,15 +37,16 @@ test('mainnet shows only the venues that are on mainnet, and testnet shows them
await expect(page.getByText('Not on mainnet yet')).toHaveCount(0)
})
-test('the root opens on Stellar mainnet, and the menu still reaches the other chains', async ({
+test('old addresses open on Stellar mainnet, and the menu still reaches the other chains', async ({
page,
}) => {
- await page.goto('/')
+ await page.goto('/intents')
await expect(page).toHaveURL(/\/stellar-mainnet\/intents$/)
await expect(page.getByRole('button', { name: /^Chain: Stellar mainnet/ })).toBeVisible()
await page.getByRole('button', { name: /^Chain: / }).click()
- await expect(page.getByRole('menuitem', { name: /^Arc\b/ })).toBeEnabled()
+ await expect(page.getByRole('menuitem', { name: /^NEAR mainnet$/ })).toBeEnabled()
+ await expect(page.getByRole('menuitem', { name: /^Arc\b/ })).toHaveCount(0)
await expect(page.getByRole('menuitem', { name: /Stellar testnet/ })).toBeEnabled()
await expect(page.getByRole('menuitem', { name: /Stellar mainnet/ })).toBeEnabled()
})
diff --git a/apps/dapp/hooks/use-cashout.ts b/apps/dapp/hooks/use-cashout.ts
new file mode 100644
index 00000000..010cb1e7
--- /dev/null
+++ b/apps/dapp/hooks/use-cashout.ts
@@ -0,0 +1,419 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import { walletFetch } from '../lib/api/network-fetch'
+import type { SignedOutcome, SigningPayload } from '../lib/chains/types'
+import {
+ accountMismatch,
+ afterSubmit,
+ afterWallet,
+ canSign,
+ depositConfirmed,
+ isLocked,
+ nextAttempt,
+ orderPhase,
+ shouldPoll,
+ type CashoutAttempt,
+ type CashoutCurrency,
+ type CashoutOrderView,
+ type CashoutPhase,
+} from '../lib/weave/cashout-view'
+import type { CashoutCurrencyCode } from '../lib/weave/parse-cashout'
+import { isCashoutChain } from '../lib/weave/types'
+import { useChain } from '../providers/chain-provider'
+import { useWallet } from './use-wallet'
+
+const POLL_MS = 5_000
+
+export interface CashoutDetails {
+ currency: string
+ institution: string
+ accountIdentifier: string
+ amount: string
+}
+
+export interface CashoutSeed {
+ amount?: string
+ currency?: CashoutCurrencyCode
+}
+
+interface CashoutState {
+ phase: CashoutPhase
+ order?: CashoutOrderView | undefined
+ /** The wallet the order was made for. The only one allowed to sign it. */
+ account?: string | undefined
+ hash?: string | undefined
+ explorerUrl?: string | undefined
+ error?: string | undefined
+}
+
+export interface Cashout extends CashoutState {
+ /** Set while the card is on screen. A new key means a fresh form. */
+ session: { key: number; seed: CashoutSeed } | null
+ /** True while money may be moving: the card stays, and no other cash-out can start. */
+ locked: boolean
+ open: (seed: CashoutSeed) => void
+ dismiss: () => void
+ create: (details: CashoutDetails) => void
+ backToForm: () => void
+ pay: () => void
+ retryReview: () => void
+ /** "I checked, try again": asks about the order once more, then offers signing only if it hasn't moved. */
+ checkedTryAgain: () => void
+ startOver: () => void
+}
+
+interface ApiError {
+ error?: string
+ message?: string
+}
+
+function messageOf(body: ApiError, fallback: string): string {
+ return body.message ?? fallback
+}
+
+async function readBody(res: Response): Promise {
+ try {
+ return (await res.json()) as T & ApiError
+ } catch {
+ return {} as T & ApiError
+ }
+}
+
+/** The currencies a chain pays out in. Nothing at all reads as none available. */
+export async function fetchCashoutCurrencies(chain: string): Promise {
+ try {
+ const res = await walletFetch(`/api/cashout/options?chain=${encodeURIComponent(chain)}`)
+ if (!res.ok) return []
+ const body = await readBody<{ currencies?: CashoutCurrency[] }>(res)
+ return body.currencies ?? []
+ } catch {
+ return []
+ }
+}
+
+async function readOrder(id: string): Promise {
+ try {
+ const res = await walletFetch(`/api/cashout/orders/${id}`)
+ if (!res.ok) return undefined
+ return (await readBody<{ order?: CashoutOrderView }>(res)).order
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * One cash-out, from the form to the account.
+ *
+ * form -> creating -> review -> signing -> submitting
+ * -> tracking | pending | check | failed
+ *
+ * Lives beside the chat, not inside the card, so a cash-out under way survives
+ * the card leaving the screen and blocks another from starting.
+ */
+export function useCashout(): Cashout {
+ const { slug, adapter } = useChain()
+ const { address } = useWallet()
+ const [state, setState] = useState({ phase: 'form' })
+ const [opened, setOpened] = useState<{
+ key: number
+ seed: CashoutSeed
+ chain: string
+ } | null>(null)
+
+ const attempt = useRef(null)
+ const sending = useRef(false)
+ // Read at the last moment before the wallet opens: building takes a while.
+ const connected = useRef(address)
+ useEffect(() => {
+ connected.current = address
+ }, [address])
+ // A request answered after the person moved on must not bring its step back.
+ const generation = useRef(0)
+
+ const locked = isLocked(state.phase, state.order)
+
+ const open = useCallback(
+ (seed: CashoutSeed) => {
+ if (locked) return
+ generation.current += 1
+ setState({ phase: 'form' })
+ setOpened((o) => ({ key: (o?.key ?? 0) + 1, seed, chain: slug }))
+ },
+ [locked, slug]
+ )
+
+ const dismiss = useCallback(() => {
+ if (locked) return
+ generation.current += 1
+ setOpened(null)
+ setState({ phase: 'form' })
+ }, [locked])
+
+ const create = useCallback(
+ (values: CashoutDetails) => {
+ if (address === undefined) {
+ setState({ phase: 'failed', error: 'Connect a wallet first.' })
+ return
+ }
+ if (!isCashoutChain(slug)) return
+ const account = address
+ const chain = slug
+
+ attempt.current = nextAttempt(
+ attempt.current,
+ [
+ account,
+ chain,
+ values.currency,
+ values.institution,
+ values.accountIdentifier,
+ values.amount,
+ ].join('|'),
+ () => crypto.randomUUID()
+ )
+ const idempotencyKey = attempt.current.key
+
+ generation.current += 1
+ const run = generation.current
+ setState({ phase: 'creating' })
+ void (async () => {
+ try {
+ const res = await walletFetch('/api/cashout/orders', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ chain, account, ...values, idempotencyKey }),
+ })
+ const body = await readBody<{ order?: CashoutOrderView }>(res)
+ if (body.error === 'order_closed') attempt.current = null
+ if (run !== generation.current) return
+ if (!res.ok || body.order === undefined) {
+ setState({
+ phase: 'failed',
+ error: messageOf(body, 'The payout could not be set up. Nothing was sent.'),
+ })
+ return
+ }
+ setState({ phase: orderPhase(body.order), order: body.order, account })
+ } catch {
+ if (run !== generation.current) return
+ setState({
+ phase: 'failed',
+ error:
+ 'No answer from the network. Nothing was sent. Trying again uses the same request, so it will not make a second order.',
+ })
+ }
+ })()
+ },
+ [address, slug]
+ )
+
+ const pay = useCallback(() => {
+ const order = state.order
+ const account = state.account
+ if (!canSign(state.phase, order) || order === undefined || sending.current) return
+ if (account === undefined) return
+ const wrongWallet = accountMismatch(address, account)
+ if (wrongWallet !== undefined) {
+ setState((s) => ({ ...s, error: wrongWallet }))
+ return
+ }
+ sending.current = true
+ const id = order.id
+
+ void (async () => {
+ try {
+ setState((s) => ({ ...s, phase: 'signing', error: undefined }))
+
+ let signing: SigningPayload | undefined
+ try {
+ const res = await walletFetch(`/api/cashout/orders/${id}/build`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ })
+ const body = await readBody<{ signing?: SigningPayload }>(res)
+ if (!res.ok || body.signing === undefined) {
+ const error = messageOf(body, 'The deposit could not be built. Nothing was sent.')
+ setState((s) => ({ ...s, phase: 'failed', error }))
+ return
+ }
+ signing = body.signing
+ } catch {
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error: 'Could not reach the network. Nothing was sent.',
+ }))
+ return
+ }
+
+ const switched = accountMismatch(connected.current, account)
+ if (switched !== undefined) {
+ setState((s) => ({ ...s, phase: 'review', error: switched }))
+ return
+ }
+
+ const family = signing.family
+ let outcome: SignedOutcome
+ try {
+ const asked = adapter.signPrepared?.(signing)
+ if (asked === undefined) {
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error: 'This wallet cannot sign here yet. Nothing was sent.',
+ }))
+ return
+ }
+ outcome = await asked
+ } catch {
+ outcome = { ok: false, reason: 'wallet_error' }
+ }
+
+ const walletStep = afterWallet(outcome, family)
+ if (walletStep.next === 'review') {
+ setState((s) => ({ ...s, phase: 'review' }))
+ return
+ }
+ if (walletStep.next === 'failed') {
+ setState((s) => ({ ...s, phase: 'failed', error: walletStep.error }))
+ return
+ }
+ if (walletStep.next === 'check') {
+ const latest = await readOrder(id)
+ setState((s) => {
+ const current = latest ?? s.order
+ const moved = current !== undefined && depositConfirmed(current)
+ return { ...s, phase: moved ? 'tracking' : 'check', order: current }
+ })
+ return
+ }
+
+ setState((s) => ({ ...s, phase: 'submitting' }))
+ let answer: unknown
+ try {
+ const res = await walletFetch(`/api/cashout/orders/${id}/submit`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signed: walletStep.signed }),
+ })
+ answer = await res.json()
+ } catch {
+ answer = undefined
+ }
+
+ const submitStep = afterSubmit(answer, family)
+ if (submitStep.next === 'tracking') {
+ setState((s) => ({
+ ...s,
+ phase: 'tracking',
+ hash: submitStep.hash,
+ explorerUrl: submitStep.explorerUrl,
+ error: undefined,
+ }))
+ return
+ }
+ setState((s) => ({ ...s, phase: submitStep.next, error: submitStep.error }))
+ } finally {
+ sending.current = false
+ }
+ })()
+ }, [state.phase, state.order, state.account, address, adapter])
+
+ const backToForm = useCallback(() => {
+ setState((s) => (isLocked(s.phase, s.order) ? s : { phase: 'form' }))
+ }, [])
+
+ const retryReview = useCallback(() => {
+ setState((s) =>
+ s.phase === 'failed' && s.order !== undefined
+ ? { ...s, phase: 'review', error: undefined }
+ : s
+ )
+ }, [])
+
+ const checkedTryAgain = useCallback(() => {
+ const id = state.phase === 'check' ? state.order?.id : undefined
+ if (id === undefined) return
+ void readOrder(id).then((latest) => {
+ setState((s) => {
+ if (s.phase !== 'check') return s
+ const current = latest ?? s.order
+ const moved = current !== undefined && depositConfirmed(current)
+ return { ...s, phase: moved ? 'tracking' : 'review', order: current }
+ })
+ })
+ }, [state.phase, state.order?.id])
+
+ const startOver = useCallback(() => {
+ if (locked) return
+ generation.current += 1
+ attempt.current = null
+ setState({ phase: 'form' })
+ setOpened((o) => ({ key: (o?.key ?? 0) + 1, seed: {}, chain: slug }))
+ }, [locked, slug])
+
+ const trackedId =
+ (state.phase === 'tracking' || state.phase === 'pending' || state.phase === 'check') &&
+ state.order !== undefined &&
+ shouldPoll(state.order.status)
+ ? state.order.id
+ : undefined
+
+ useEffect(() => {
+ if (trackedId === undefined) return
+ const id = trackedId
+ let cancelled = false
+
+ async function poll(): Promise {
+ try {
+ const res = await walletFetch(`/api/cashout/orders/${id}`)
+ if (!res.ok || cancelled) return
+ const body = (await res.json()) as { order?: CashoutOrderView }
+ const next = body.order
+ if (next === undefined || cancelled) return
+ setState((s) => {
+ if (s.order?.id !== next.id) return s
+ const unsure = s.phase === 'pending' || s.phase === 'check'
+ if (unsure && depositConfirmed(next)) {
+ return { ...s, phase: 'tracking', order: next, error: undefined }
+ }
+ return s.phase === 'tracking' || unsure ? { ...s, order: next } : s
+ })
+ } catch {
+ // The next tick tries again.
+ }
+ }
+
+ void poll()
+ const timer = setInterval(() => void poll(), POLL_MS)
+ return () => {
+ cancelled = true
+ clearInterval(timer)
+ }
+ }, [trackedId])
+
+ const status = state.order?.status
+ useEffect(() => {
+ if (status !== undefined && !shouldPoll(status)) attempt.current = null
+ }, [status])
+
+ const onThisChain = opened !== null && opened.chain === slug
+ const session = opened !== null && (onThisChain || locked) ? opened : null
+
+ return {
+ ...state,
+ session,
+ locked,
+ open,
+ dismiss,
+ create,
+ backToForm,
+ pay,
+ retryReview,
+ checkedTryAgain,
+ startOver,
+ }
+}
diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts
new file mode 100644
index 00000000..4d623aa2
--- /dev/null
+++ b/apps/dapp/hooks/use-chain-action.ts
@@ -0,0 +1,151 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+
+import type { ChainActionRequest, PreparedAction, SubmitResult } from '../lib/chains/types'
+import { useChain } from '../providers/chain-provider'
+import { useWallet } from './use-wallet'
+
+export type ChainActionPhase =
+ | 'idle'
+ | 'building'
+ | 'review'
+ | 'signing'
+ | 'submitting'
+ | 'settled'
+ /** Sent, with the outcome not known yet. Not a failure: the money has moved. */
+ | 'pending'
+ | 'failed'
+
+export interface ChainActionState {
+ phase: ChainActionPhase
+ asked?: string
+ prepared?: PreparedAction
+ hash?: string
+ explorerUrl?: string
+ error?: string
+}
+
+export interface ChainAction extends ChainActionState {
+ prepare: (request: ChainActionRequest, asked: string) => void
+ /** Shows a message in the card without building anything, for what can't be read. */
+ refuse: (message: string, asked: string) => void
+ confirm: () => void
+ reset: () => void
+}
+
+const EMPTY: ChainActionState = { phase: 'idle' }
+
+/** One action on a chain that is not Stellar: one build, one signature, one submission. */
+export function useChainAction(): ChainAction {
+ const { slug, adapter } = useChain()
+ const { address } = useWallet()
+ const [state, setState] = useState(EMPTY)
+
+ const reset = useCallback(() => setState(EMPTY), [])
+
+ const refuse = useCallback((message: string, asked: string) => {
+ setState({ phase: 'failed', asked, error: message })
+ }, [])
+
+ const prepare = useCallback(
+ (request: ChainActionRequest, asked: string) => {
+ if (address === undefined) {
+ setState({ phase: 'failed', asked, error: 'Connect a wallet first.' })
+ return
+ }
+ const account = address
+
+ async function run(): Promise {
+ setState({ phase: 'building', asked })
+ const res = await fetch(`/api/chain/${slug}/prepare`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account, request }),
+ })
+ const body = (await res.json()) as { prepared?: PreparedAction; error?: string }
+ if (body.prepared === undefined) {
+ setState({
+ phase: 'failed',
+ asked,
+ error: body.error ?? 'This could not be built. Nothing was signed or sent.',
+ })
+ return
+ }
+ setState({ phase: 'review', asked, prepared: body.prepared })
+ }
+
+ void run().catch(() => {
+ setState({ phase: 'failed', asked, error: 'Could not reach the network.' })
+ })
+ },
+ [address, slug]
+ )
+
+ const confirm = useCallback(() => {
+ const prepared = state.prepared
+ if (prepared === undefined || address === undefined) return
+ const account = address
+
+ async function run(prepared: PreparedAction): Promise {
+ setState((s) => ({ ...s, phase: 'signing' }))
+
+ const outcome = await adapter.signPrepared?.(prepared.signing)
+ if (outcome === undefined) {
+ setState((s) => ({ ...s, phase: 'failed', error: 'This wallet cannot sign here yet.' }))
+ return
+ }
+ if (!outcome.ok) {
+ // Declining returns to review: the action is still on offer.
+ if (outcome.reason === 'rejected') {
+ setState((s) => ({ ...s, phase: 'review' }))
+ return
+ }
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error: outcome.detail ?? 'The wallet could not sign this.',
+ }))
+ return
+ }
+
+ setState((s) => ({ ...s, phase: 'submitting' }))
+ const res = await fetch(`/api/chain/${slug}/submit`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ account,
+ venueId: prepared.venueId,
+ signed: outcome.signed,
+ ...(prepared.context !== undefined ? { context: prepared.context } : {}),
+ }),
+ })
+ const result = (await res.json()) as SubmitResult
+ if (!result.ok) {
+ setState((s) => ({
+ ...s,
+ phase: result.pending === true ? 'pending' : 'failed',
+ error: result.error,
+ }))
+ return
+ }
+ setState((s) => ({
+ ...s,
+ phase: 'settled',
+ hash: result.hash,
+ explorerUrl: result.explorerUrl,
+ }))
+ }
+
+ void run(prepared).catch(() => {
+ setState((s) => ({
+ ...s,
+ phase: 'failed',
+ error:
+ "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.",
+ }))
+ })
+ }, [state.prepared, address, adapter, slug])
+
+ return { ...state, prepare, refuse, confirm, reset }
+}
diff --git a/apps/dapp/hooks/use-go-to-chain.ts b/apps/dapp/hooks/use-go-to-chain.ts
new file mode 100644
index 00000000..6c3ba590
--- /dev/null
+++ b/apps/dapp/hooks/use-go-to-chain.ts
@@ -0,0 +1,30 @@
+'use client'
+
+import { chainSegment, isMultiNetwork } from '@intent/config'
+import type { ChainSlug } from '@intent/types'
+import { usePathname, useRouter } from 'next/navigation'
+
+import { rememberChain } from '../lib/wallet/chain-cookie'
+import { pathUnderChain } from '../lib/wallet/chain-path'
+import { useChain } from '../providers/chain-provider'
+
+/**
+ * Moves to a chain that has a connected wallet, staying on the same screen, and
+ * remembers it as where the app opens next time.
+ */
+export function useGoToChain(): (slug: ChainSlug) => void {
+ const { segment: current } = useChain()
+ const pathname = usePathname()
+ const router = useRouter()
+
+ return (slug) => {
+ const segment = chainSegment(slug)
+ rememberChain(segment)
+ if (segment === current) return
+ const href = `${pathUnderChain(pathname, segment)}${window.location.search}`
+ // A full load into Stellar when several networks are served: the network
+ // decides what every cached call means, and none of it should carry across.
+ if (slug === 'stellar' && isMultiNetwork()) window.location.assign(href)
+ else router.push(href)
+ }
+}
diff --git a/apps/dapp/lib/__tests__/chain-action.test.ts b/apps/dapp/lib/__tests__/chain-action.test.ts
new file mode 100644
index 00000000..fccc6c45
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-action.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest'
+
+import { looksLikeChainAction, parseChainAction } from '../chains/parse-action'
+import { prepareChainAction } from '../chains/prepare'
+import { chainModule } from '../chains/registry'
+import {
+ ChainActionRefused,
+ toBaseUnits,
+ type ChainModule,
+ type PreparedAction,
+} from '../chains/types'
+
+describe('parseChainAction', () => {
+ it('reads a swap', () => {
+ expect(parseChainAction('Swap 50 USDC to SOL')).toMatchObject({
+ kind: 'swap',
+ amount: '50',
+ tokenIn: 'USDC',
+ tokenOut: 'SOL',
+ })
+ })
+
+ it('reads a named venue and thousands separators', () => {
+ expect(parseChainAction('please convert 1,000.5 usdc into usdt on cetus.')).toMatchObject({
+ amount: '1000.5',
+ tokenOut: 'usdt',
+ venue: 'cetus',
+ })
+ })
+
+ it('reads a supply and a stake', () => {
+ expect(parseChainAction('supply 100 USDC on Kamino')).toMatchObject({
+ kind: 'supply',
+ tokenIn: 'USDC',
+ venue: 'Kamino',
+ })
+ expect(parseChainAction('stake 2 NEAR with rNEAR')).toMatchObject({
+ kind: 'stake',
+ tokenIn: 'NEAR',
+ venue: 'rNEAR',
+ })
+ })
+
+ it('does not guess at anything looser', () => {
+ expect(parseChainAction('what is my balance?')).toBeNull()
+ expect(parseChainAction('swap all my USDC')).toBeNull()
+ expect(parseChainAction('swap 50 USDC to SOL and send it to bob')).toBeNull()
+ expect(parseChainAction('I will not swap 50 USDC to SOL')).toBeNull()
+ })
+
+ it('tells action sentences from questions', () => {
+ expect(looksLikeChainAction('swap all my USDC')).toBe(true)
+ expect(looksLikeChainAction('what is Solana?')).toBe(false)
+ })
+})
+
+describe('toBaseUnits', () => {
+ it('scales without floating point', () => {
+ expect(toBaseUnits('1.5', 6)).toBe('1500000')
+ expect(toBaseUnits('0.000001', 6)).toBe('1')
+ expect(toBaseUnits('100000000000.1', 24)).toBe('100000000000100000000000000000000000')
+ })
+
+ it('refuses what it cannot represent', () => {
+ expect(() => toBaseUnits('1.1234567', 6)).toThrow(ChainActionRefused)
+ expect(() => toBaseUnits('0', 6)).toThrow(ChainActionRefused)
+ expect(() => toBaseUnits('abc', 6)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('chainModule', () => {
+ it('finds only chains that use this path', () => {
+ expect(chainModule('solana')?.chain).toBe('solana')
+ expect(chainModule('stellar')).toBeUndefined()
+ expect(chainModule('constructor')).toBeUndefined()
+ })
+})
+
+describe('prepareChainAction', () => {
+ const prepared: PreparedAction = {
+ venueId: 'a',
+ venueName: 'A',
+ kind: 'swap',
+ summary: 's',
+ pay: { amount: '1', symbol: 'USDC' },
+ notes: [],
+ signing: { family: 'solana', transaction: 'x' },
+ }
+ const usdc = { symbol: 'USDC', id: 'u', decimals: 6 }
+ const sol = { symbol: 'SOL', id: 's', decimals: 9 }
+
+ function moduleWith(
+ overrides: Partial = {},
+ assertSafe: () => void = () => undefined
+ ) {
+ return {
+ chain: 'solana',
+ tokens: [usdc, sol],
+ venues: [
+ {
+ id: 'a',
+ name: 'Alpha',
+ kinds: ['swap'],
+ prepare: async () => prepared,
+ assertSafe,
+ },
+ ],
+ defaultVenue: { swap: 'a' },
+ priceUsd: async () => 1,
+ submit: async () => ({ ok: false, error: 'no' }),
+ ...overrides,
+ } as ChainModule
+ }
+
+ 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)
+ })
+
+ it('refuses a token it does not know', async () => {
+ await expect(
+ 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 () => {
+ await expect(
+ prepareChainAction(moduleWith({ priceUsd: async () => undefined }), swap, 'acct', {})
+ ).rejects.toThrow(/could not be valued/)
+ })
+
+ it('refuses a trade over the cap', async () => {
+ await expect(
+ prepareChainAction(moduleWith(), { ...swap, amount: '501' }, 'acct', {})
+ ).rejects.toThrow(/caps each trade/)
+ })
+
+ it('refuses a venue that cannot do this', async () => {
+ await expect(
+ prepareChainAction(moduleWith(), { ...swap, venue: 'nope' }, 'acct', {})
+ ).rejects.toThrow(/can't swap here/)
+ })
+
+ it('never hands over a payload that fails the safety check', async () => {
+ const unsafe = (): void => {
+ throw new ChainActionRefused('unexpected program')
+ }
+ await expect(prepareChainAction(moduleWith({}, unsafe), swap, 'acct', {})).rejects.toThrow(
+ /unexpected program/
+ )
+ })
+})
diff --git a/apps/dapp/lib/__tests__/chain-registry.test.ts b/apps/dapp/lib/__tests__/chain-registry.test.ts
index ff89060f..612c3864 100644
--- a/apps/dapp/lib/__tests__/chain-registry.test.ts
+++ b/apps/dapp/lib/__tests__/chain-registry.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
CHAIN_DESCRIPTORS,
CHAIN_ORDER,
+ CONNECT_CHAINS,
accountExplorerUrl,
isChainSlug,
} from '@intent/config'
@@ -15,9 +16,18 @@ describe('chain registry', () => {
expect(isChainSlug('')).toBe(false)
})
- it('describes both chains with the right family', () => {
+ it('describes each chain with the right family', () => {
expect(CHAIN_DESCRIPTORS.arc.family).toBe('evm')
expect(CHAIN_DESCRIPTORS.stellar.family).toBe('stellar')
+ expect(CHAIN_DESCRIPTORS.solana.family).toBe('solana')
+ expect(CHAIN_DESCRIPTORS.avalanche.family).toBe('evm')
+ expect(CHAIN_DESCRIPTORS.sui.family).toBe('sui')
+ expect(CHAIN_DESCRIPTORS.monad.family).toBe('evm')
+ expect(CHAIN_DESCRIPTORS.near.family).toBe('near')
+ })
+
+ it('offers the six connectable chains in order, without Arc', () => {
+ expect(CONNECT_CHAINS).toEqual(['stellar', 'solana', 'avalanche', 'sui', 'monad', 'near'])
})
it('lists every descriptor in the display order', () => {
@@ -29,5 +39,10 @@ describe('chain registry', () => {
// concatenate these by hand.
expect(accountExplorerUrl('arc', '0xabc')).toContain('/address/0xabc')
expect(accountExplorerUrl('stellar', 'GABC')).toContain('/account/GABC')
+ expect(accountExplorerUrl('solana', 'abc')).toBe('https://solscan.io/account/abc')
+ expect(accountExplorerUrl('avalanche', '0xabc')).toBe('https://snowtrace.io/address/0xabc')
+ expect(accountExplorerUrl('sui', '0xabc')).toBe('https://suiscan.xyz/mainnet/account/0xabc')
+ expect(accountExplorerUrl('monad', '0xabc')).toBe('https://monadvision.com/address/0xabc')
+ expect(accountExplorerUrl('near', 'a.near')).toBe('https://nearblocks.io/address/a.near')
})
})
diff --git a/apps/dapp/lib/__tests__/chain-title.test.ts b/apps/dapp/lib/__tests__/chain-title.test.ts
new file mode 100644
index 00000000..fa44444a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-title.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+
+import { chainTitle } from '../chain-title'
+
+describe('chainTitle', () => {
+ it('is only the name on a mainnet', () => {
+ expect(chainTitle('Sui', 'Sui mainnet')).toBe('Sui')
+ expect(chainTitle('Stellar', 'Stellar mainnet')).toBe('Stellar')
+ expect(chainTitle('NEAR', 'NEAR mainnet')).toBe('NEAR')
+ })
+
+ it('says so on a testnet, which must not pass for real money', () => {
+ expect(chainTitle('Stellar', 'Stellar testnet')).toBe('Stellar testnet')
+ expect(chainTitle('Arc', 'Arc testnet')).toBe('Arc testnet')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/extension-console.test.ts b/apps/dapp/lib/__tests__/extension-console.test.ts
new file mode 100644
index 00000000..939b626a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/extension-console.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { isExtensionOnlyStack, quietExtensionErrors } from '../extension-console'
+
+const SLUSH = 'chrome-extension://opcgpfmipidbgpenhmajoajpbobppdil/dapp-interface.js'
+const APP = 'https://localhost:3001/_next/static/chunks/app/page.js'
+
+const fromExtension = `Error
+ at (${SLUSH}:7:636)
+ at x (${SLUSH}:7:1175)
+ at Array.forEach ()
+ at n (${SLUSH}:7:9861)`
+
+const throughExtension = `Error
+ at (${SLUSH}:7:636)
+ at connectTo (${APP}:5:2)`
+
+const appCallbackRunByExtension = `Error
+ at onChange (${APP}:5:2)
+ at emit (${SLUSH}:7:636)`
+
+describe('isExtensionOnlyStack', () => {
+ it('matches a stack made only of extension frames', () => {
+ expect(isExtensionOnlyStack(fromExtension)).toBe(true)
+ })
+
+ it('leaves alone an error the app is part of', () => {
+ expect(isExtensionOnlyStack(throughExtension)).toBe(false)
+ expect(isExtensionOnlyStack(appCallbackRunByExtension)).toBe(false)
+ })
+
+ it('leaves alone a stack that names no extension, or none at all', () => {
+ expect(isExtensionOnlyStack(`Error\n at b (${APP}:2:2)`)).toBe(false)
+ expect(isExtensionOnlyStack('Error')).toBe(false)
+ expect(isExtensionOnlyStack(undefined)).toBe(false)
+ })
+})
+
+describe('quietExtensionErrors', () => {
+ it('sends extension noise to debug and everything else through', () => {
+ const error = vi.fn()
+ const debug = vi.fn()
+ const target = { error, debug }
+ let stack = fromExtension
+ quietExtensionErrors(target, () => stack)
+
+ target.error('wallet noise')
+ expect(error).not.toHaveBeenCalled()
+ expect(debug).toHaveBeenCalledWith('wallet noise')
+
+ stack = throughExtension
+ target.error('real problem')
+ expect(error).toHaveBeenCalledWith('real problem')
+ })
+
+ it('passes everything through when the real stack is from the caller, not an extension', () => {
+ const error = vi.fn()
+ const debug = vi.fn()
+ const target = { error, debug }
+ quietExtensionErrors(target)
+ target.error('from a test file')
+ expect(error).toHaveBeenCalledWith('from a test file')
+ expect(debug).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/middleware-network.test.ts b/apps/dapp/lib/__tests__/middleware-network.test.ts
index c9e056d2..29b1e262 100644
--- a/apps/dapp/lib/__tests__/middleware-network.test.ts
+++ b/apps/dapp/lib/__tests__/middleware-network.test.ts
@@ -110,16 +110,11 @@ describe('the gate, per network', () => {
describe('where the root and the old unprefixed addresses go', () => {
const location = (res: Response): URL => new URL(res.headers.get('location') as string)
- it('opens on Stellar mainnet when both networks are served', async () => {
- const res = await run('http://app.test/', { env: BOTH })
- expect(res.status).toBe(307)
- expect(location(res).pathname).toBe('/stellar-mainnet/intents')
- })
-
- it('opens on the one Stellar address when one network is served', async () => {
- const res = await run('http://app.test/', {})
- expect(res.status).toBe(307)
- expect(location(res).pathname).toBe('/stellar/intents')
+ it('leaves the root where it is: it is the connect page, with no chain in the address', async () => {
+ for (const env of [BOTH, {}]) {
+ const res = await run('http://app.test/', { env })
+ expect(res.headers.get('location')).toBeNull()
+ }
})
it('sends an old unprefixed screen to the same screen on the home chain, keeping the query', async () => {
@@ -135,14 +130,10 @@ describe('where the root and the old unprefixed addresses go', () => {
}
})
- it('does not put the root behind the gate: the visitor lands on mainnet and is then asked to verify', async () => {
- const root = await run('http://app.test/', { env: BOTH })
- expect(location(root).pathname).toBe('/stellar-mainnet/intents')
- const page = await run('http://app.test/stellar-mainnet/intents', {
- env: { ...BOTH, ACCESS_GATE: 'on' },
- })
- expect(location(page).pathname).toBe('/verify')
- expect(location(page).searchParams.get('next')).toBe('/stellar-mainnet/intents')
+ it('asks for verification before the connect page when the email gate is on', async () => {
+ const root = await run('http://app.test/', { env: { ...BOTH, ACCESS_GATE: 'on' } })
+ expect(location(root).pathname).toBe('/verify')
+ expect(location(root).searchParams.get('next')).toBe('/')
})
})
diff --git a/apps/dapp/lib/__tests__/native-coin-price.test.ts b/apps/dapp/lib/__tests__/native-coin-price.test.ts
new file mode 100644
index 00000000..a300c381
--- /dev/null
+++ b/apps/dapp/lib/__tests__/native-coin-price.test.ts
@@ -0,0 +1,95 @@
+import { CHAIN_DESCRIPTORS, CHAIN_ORDER } from '@intent/config'
+import { describe, expect, it } from 'vitest'
+
+import { fetchNativeCoinPrice, formatCoinUsd } from '../prices/native-coin'
+
+const NOW = 1_800_000_000_000
+const FRESH = NOW / 1000 - 30
+
+/** Answers like DefiLlama: under the key asked for, or nothing for a key it doesn't know. */
+function llama(
+ coins: Record
+): { fetcher: typeof fetch; urls: string[] } {
+ const urls: string[] = []
+ const fetcher = (async (input: RequestInfo | URL) => {
+ urls.push(String(input))
+ return new Response(JSON.stringify({ coins }))
+ }) as typeof fetch
+ return { fetcher, urls }
+}
+
+function coin(key: string, symbol: string, price: unknown = 12.5) {
+ return { [key]: { price, symbol, timestamp: FRESH, confidence: 0.99 } }
+}
+
+const IDS = {
+ arc: 'usd-coin',
+ stellar: 'stellar',
+ solana: 'solana',
+ avalanche: 'avalanche-2',
+ sui: 'sui',
+ monad: 'monad',
+ near: 'near',
+} as const
+
+describe('fetchNativeCoinPrice', () => {
+ it.each(CHAIN_ORDER)("prices %s's own coin", async (slug) => {
+ const symbol = CHAIN_DESCRIPTORS[slug].nativeCurrency.symbol
+ const key = `coingecko:${IDS[slug]}`
+ const { fetcher, urls } = llama(coin(key, symbol))
+
+ const got = await fetchNativeCoinPrice(slug, fetcher, NOW)
+
+ expect(got).toEqual({ symbol, usd: 12.5, asOf: new Date(FRESH * 1000).toISOString() })
+ expect(urls[0]).toBe(`https://coins.llama.fi/prices/current/${key}`)
+ })
+
+ it('refuses another coin answered under the id it asked for', async () => {
+ const { fetcher } = llama(coin('coingecko:sui', 'WRONG'))
+ expect(await fetchNativeCoinPrice('sui', fetcher, NOW)).toBeUndefined()
+ })
+
+ it('refuses a price more than five minutes old', async () => {
+ const { fetcher } = llama({
+ 'coingecko:sui': { price: 1, symbol: 'SUI', timestamp: NOW / 1000 - 301, confidence: 0.99 },
+ })
+ expect(await fetchNativeCoinPrice('sui', fetcher, NOW)).toBeUndefined()
+ })
+
+ it('refuses a low-confidence price', async () => {
+ const { fetcher } = llama({
+ 'coingecko:sui': { price: 1, symbol: 'SUI', timestamp: FRESH, confidence: 0.5 },
+ })
+ expect(await fetchNativeCoinPrice('sui', fetcher, NOW)).toBeUndefined()
+ })
+
+ it.each([0, -1, 'nope', null])('refuses a price of %s', async (price) => {
+ const { fetcher } = llama(coin('coingecko:sui', 'SUI', price))
+ expect(await fetchNativeCoinPrice('sui', fetcher, NOW)).toBeUndefined()
+ })
+
+ it('answers nothing when the coin is missing, the call fails or the network is down', async () => {
+ expect(await fetchNativeCoinPrice('sui', llama({}).fetcher, NOW)).toBeUndefined()
+
+ const failing = (async () => new Response('', { status: 500 })) as typeof fetch
+ expect(await fetchNativeCoinPrice('sui', failing, NOW)).toBeUndefined()
+
+ const down = (async () => {
+ throw new Error('offline')
+ }) as typeof fetch
+ expect(await fetchNativeCoinPrice('sui', down, NOW)).toBeUndefined()
+ })
+})
+
+describe('formatCoinUsd', () => {
+ it('shows cents from a dollar up', () => {
+ expect(formatCoinUsd(93.31975)).toBe('$93.32')
+ expect(formatCoinUsd(1)).toBe('$1.00')
+ expect(formatCoinUsd(64250.5)).toBe('$64,250.50')
+ })
+
+ it('keeps enough places to read a coin under a dollar', () => {
+ expect(formatCoinUsd(0.898238)).toBe('$0.8982')
+ expect(formatCoinUsd(0.0181207)).toBe('$0.0181')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/nav-guard.test.ts b/apps/dapp/lib/__tests__/nav-guard.test.ts
new file mode 100644
index 00000000..c617e97a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/nav-guard.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+
+import { createNavGuard } from '../nav-guard'
+
+describe('createNavGuard', () => {
+ it('follows the first click and ignores repeats while it is pending', () => {
+ const guard = createNavGuard()
+ expect(guard.follow('/sui/apps')).toBe(true)
+ expect(guard.follow('/sui/apps')).toBe(false)
+ expect(guard.follow('/sui/apps')).toBe(false)
+ })
+
+ it('lets a different destination take over', () => {
+ const guard = createNavGuard()
+ expect(guard.follow('/sui/apps')).toBe(true)
+ expect(guard.follow('/sui/agents')).toBe(true)
+ expect(guard.follow('/sui/apps')).toBe(true)
+ })
+
+ it('allows the same link again once the navigation settled', () => {
+ const guard = createNavGuard()
+ guard.follow('/sui/apps')
+ guard.settle()
+ expect(guard.follow('/sui/apps')).toBe(true)
+ })
+
+ it('does not leave a link dead if a navigation never finishes', () => {
+ let now = 0
+ const guard = createNavGuard(60_000, () => now)
+ guard.follow('/sui/apps')
+ now = 59_999
+ expect(guard.follow('/sui/apps')).toBe(false)
+ now = 60_000
+ expect(guard.follow('/sui/apps')).toBe(true)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-1click.live.test.ts b/apps/dapp/lib/__tests__/near-1click.live.test.ts
new file mode 100644
index 00000000..becf6223
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-1click.live.test.ts
@@ -0,0 +1,109 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { clearTokenCache, listTokens, oneClickPrice, requestQuote } from '../chains/near/oneclick'
+import { USDC_NEAR, USDT_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+import { ChainActionRefused } from '../chains/types'
+
+/**
+ * The real 1Click API, read-only. Every quote here is `dry: true`, which creates no
+ * deposit address. Nothing signs or sends, and no account or funds are touched.
+ *
+ * The fixtures in the unit tests are written from this module's own assumptions.
+ * This is what catches an API that has moved away from them. `requestQuote` checks
+ * each quote's signature against the real key.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const ME = 'root.near'
+const WNEAR = `nep141:${WRAP_NEAR}`
+const USDC = `nep141:${USDC_NEAR}`
+const USDT = `nep141:${USDT_NEAR}`
+const VAULT = 'nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near'
+
+function dry(originAsset: string, destinationAsset: string, amount: string, intents = false) {
+ return requestQuote({
+ dry: true,
+ originAsset,
+ destinationAsset,
+ amount,
+ account: ME,
+ recipientType: intents ? 'INTENTS' : 'DESTINATION_CHAIN',
+ deadline: new Date(Date.now() + 5 * 60_000).toISOString(),
+ slippageBps: 100,
+ })
+}
+
+function lossOf(envelope: Awaited>): number {
+ return 1 - Number(envelope.quote.amountOutUsd) / Number(envelope.quote.amountInUsd)
+}
+
+beforeEach(() => clearTokenCache())
+
+describe.skipIf(SKIP)('the 1Click token list', () => {
+ it('has the exact tokens this app allows, at the decimals it assumes', async () => {
+ const tokens = await listTokens()
+ expect(tokens.find((t) => t.assetId === WNEAR)).toMatchObject({ symbol: 'wNEAR', decimals: 24 })
+ expect(tokens.find((t) => t.assetId === USDC)).toMatchObject({ symbol: 'USDC', decimals: 6 })
+ expect(tokens.find((t) => t.assetId === USDT)).toMatchObject({ symbol: 'USDT', decimals: 6 })
+ expect(tokens.find((t) => t.assetId === VAULT)).toMatchObject({
+ symbol: 'gtUSDCp',
+ decimals: 18,
+ })
+ }, 30_000)
+
+ it('carries fresh prices, and stablecoins near but not forced to $1', async () => {
+ const near = await oneClickPrice(WNEAR, 24)
+ const usdc = await oneClickPrice(USDC, 6)
+ const usdt = await oneClickPrice(USDT, 6)
+ expect(near).toBeGreaterThan(0)
+ expect(usdc).toBeGreaterThan(0.95)
+ expect(usdc).toBeLessThan(1.05)
+ expect(usdt).toBeGreaterThan(0.95)
+ expect(usdt).toBeLessThan(1.05)
+ }, 30_000)
+})
+
+describe.skipIf(SKIP)('dry quotes', () => {
+ const ONE_NEAR = '1000000000000000000000000'
+ const pairs: [string, string, string, string][] = [
+ ['USDC to NEAR', USDC, WNEAR, '1000000'],
+ ['NEAR to USDC', WNEAR, USDC, ONE_NEAR],
+ ['USDT to NEAR', USDT, WNEAR, '1000000'],
+ ['NEAR to USDT', WNEAR, USDT, ONE_NEAR],
+ ['USDC to USDT', USDC, USDT, '1000000'],
+ ['USDT to USDC', USDT, USDC, '1000000'],
+ ]
+
+ for (const [name, origin, destination, amount] of pairs) {
+ it(`${name} is signed, matches, and is within the value limit`, async () => {
+ const envelope = await dry(origin, destination, amount)
+ expect(envelope.quote.amountIn).toBe(amount)
+ expect(envelope.quote.minAmountIn).toBe(amount)
+ expect(BigInt(envelope.quote.amountOut)).toBeGreaterThan(BigInt(0))
+ expect(envelope.quote.depositAddress).toBeUndefined()
+ expect(envelope.quoteRequest).toMatchObject({
+ dry: true,
+ recipient: ME,
+ refundTo: ME,
+ originAsset: origin,
+ destinationAsset: destination,
+ })
+ expect(lossOf(envelope)).toBeLessThan(0.05)
+ expect(envelope.quoteRequest.appFees?.length ?? 0).toBeGreaterThan(0)
+ }, 45_000)
+ }
+
+ it('reaches the vault receipt token from USDC and from USDT, into an Intents balance', async () => {
+ for (const origin of [USDC, USDT]) {
+ const envelope = await dry(origin, VAULT, '100000000', true)
+ expect(envelope.quoteRequest.recipientType).toBe('INTENTS')
+ expect(lossOf(envelope)).toBeLessThan(0.05)
+ }
+ }, 45_000)
+
+ it('says "no liquidity" in a sentence for an impossible size', async () => {
+ const error = await dry(USDC, WNEAR, '50000000000000').catch((e: unknown) => e)
+ expect(error).toBeInstanceOf(ChainActionRefused)
+ expect((error as Error).message).toMatch(/no price for this right now/)
+ }, 45_000)
+})
diff --git a/apps/dapp/lib/__tests__/near-contracts.live.test.ts b/apps/dapp/lib/__tests__/near-contracts.live.test.ts
new file mode 100644
index 00000000..8c3804be
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-contracts.live.test.ts
@@ -0,0 +1,174 @@
+import { describe, expect, it } from 'vitest'
+
+import { viewCall, waitForTransaction } from '../chains/near/rpc'
+import { METAPOOL_STAKE_GAS, RNEAR_STAKE_GAS, STORAGE_DEPOSIT_YOCTO } from '../chains/near/safety'
+import { metapoolVenue, rnearVenue } from '../chains/near/stake'
+import {
+ METAPOOL_CONTRACT,
+ nearTokens,
+ RNEAR_CONTRACT,
+ USDC_NEAR,
+ USDT_NEAR,
+ WRAP_NEAR,
+} from '../chains/near/tokens'
+
+/**
+ * The real contracts, read-only: RPC view calls and a read of each contract's code.
+ * Nothing signs or sends. Preparing a stake only reads and builds JSON, so it runs
+ * here too, and what it builds is never given to a wallet.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const ME = 'root.near'
+const RPC = process.env['NEAR_RPC_URL'] ?? 'https://free.rpc.fastnear.com'
+
+async function rpc(method: string, params: unknown): Promise {
+ const response = await fetch(RPC, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 'test', method, params }),
+ signal: AbortSignal.timeout(30_000),
+ })
+ const body = (await response.json()) as { result: T }
+ return body.result
+}
+
+/** Exported function names, read from the contract's own wasm. */
+async function exportedMethods(contract: string): Promise {
+ const { code_base64: code } = await rpc<{ code_base64: string }>('query', {
+ request_type: 'view_code',
+ finality: 'final',
+ account_id: contract,
+ })
+ const bytes = Buffer.from(code, '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 length = leb()
+ const end = at + length
+ 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 []
+}
+
+describe.skipIf(SKIP)('the allowlisted contracts', () => {
+ it('report the decimals this app assumes', async () => {
+ for (const token of nearTokens) {
+ const meta = await viewCall<{ decimals: number }>(token.id, 'ft_metadata')
+ expect(meta.decimals, token.symbol).toBe(token.decimals)
+ }
+ }, 60_000)
+
+ it('charge exactly the storage amount the safety check allows', async () => {
+ for (const contract of [WRAP_NEAR, USDC_NEAR, USDT_NEAR, RNEAR_CONTRACT]) {
+ const bounds = await viewCall<{ min: string }>(contract, 'storage_balance_bounds')
+ expect(bounds.min, contract).toBe(STORAGE_DEPOSIT_YOCTO)
+ }
+ }, 60_000)
+
+ it('export every method the safety check allows', async () => {
+ const expected: Record = {
+ [WRAP_NEAR]: ['near_deposit', 'ft_transfer', 'storage_deposit', 'storage_balance_of'],
+ [USDC_NEAR]: ['ft_transfer', 'storage_deposit', 'storage_balance_of'],
+ [USDT_NEAR]: ['ft_transfer', 'storage_deposit', 'storage_balance_of'],
+ [RNEAR_CONTRACT]: ['deposit_and_stake', 'storage_deposit', 'ft_price', 'is_paused'],
+ [METAPOOL_CONTRACT]: ['deposit_and_stake', 'get_st_near_price', 'get_contract_params'],
+ }
+ for (const [contract, methods] of Object.entries(expected)) {
+ const exported = await exportedMethods(contract)
+ for (const method of methods) expect(exported, `${contract} ${method}`).toContain(method)
+ }
+ }, 120_000)
+})
+
+describe.skipIf(SKIP)('stake venues against the live contracts', () => {
+ it('prepares rNEAR, and the payload passes its own check', async () => {
+ const prepared = await rnearVenue.prepare({ kind: 'stake', amount: '2', tokenIn: 'NEAR' }, ME)
+ expect(() => rnearVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ if (prepared.signing.family !== 'near') throw new Error('wrong family')
+ const [tx] = prepared.signing.transactions
+ expect(tx?.receiverId).toBe('lst.rhealab.near')
+ expect(tx?.actions.at(-1)?.params.gas).toBe(RNEAR_STAKE_GAS.toString())
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(1)
+ expect(Number(prepared.receive?.amount)).toBeLessThan(2)
+ expect(prepared.apy?.basePercent).toBeGreaterThan(0)
+ }, 60_000)
+
+ it('prepares Meta Pool, and the payload passes its own check', async () => {
+ const prepared = await metapoolVenue.prepare(
+ { kind: 'stake', amount: '2', tokenIn: 'NEAR' },
+ ME
+ )
+ expect(() => metapoolVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ if (prepared.signing.family !== 'near') throw new Error('wrong family')
+ const [tx] = prepared.signing.transactions
+ expect(tx?.receiverId).toBe('meta-pool.near')
+ expect(tx?.actions).toHaveLength(1)
+ expect(tx?.actions[0]?.params.gas).toBe(METAPOOL_STAKE_GAS.toString())
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(1)
+ expect(Number(prepared.receive?.amount)).toBeLessThan(2)
+ }, 60_000)
+
+ it("matches the figures in Meta Pool's notes and minimum", async () => {
+ const params = await viewCall<{
+ min_deposit_amount: string
+ nslp_min_discount_basis_points: number
+ nslp_max_discount_basis_points: number
+ }>(METAPOOL_CONTRACT, 'get_contract_params')
+ expect(params.min_deposit_amount).toBe('1000000000000000000000000')
+ expect(params.nslp_min_discount_basis_points).toBe(30)
+ expect(params.nslp_max_discount_basis_points).toBe(500)
+ await expect(
+ metapoolVenue.prepare({ kind: 'stake', amount: '0.5', tokenIn: 'NEAR' }, ME)
+ ).rejects.toThrow(/at least 1 NEAR/)
+ }, 60_000)
+})
+
+describe.skipIf(SKIP)('reading a transaction', () => {
+ it('reads a real recent transaction from the tx method', async () => {
+ const block = await rpc<{ chunks: { chunk_hash: string }[] }>('block', { finality: 'final' })
+ let found: { hash: string; signer_id: string } | undefined
+ for (const chunk of block.chunks) {
+ const body = await rpc<{ transactions: { hash: string; signer_id: string }[] }>('chunk', {
+ chunk_id: chunk.chunk_hash,
+ })
+ found = body.transactions[0]
+ if (found !== undefined) break
+ }
+ if (found === undefined) throw new Error('no transaction in the latest block')
+
+ const outcome = await waitForTransaction(found.hash, found.signer_id, 20_000)
+ expect(['success', 'failed']).toContain(outcome.state)
+ }, 90_000)
+
+ it('gives up within its bound on a hash nobody sent', async () => {
+ const startedAt = Date.now()
+ const outcome = await waitForTransaction('11111111111111111111111111111111', ME, 3_000)
+ expect(outcome.state).toBe('unknown')
+ expect(Date.now() - startedAt).toBeLessThan(15_000)
+ }, 30_000)
+})
diff --git a/apps/dapp/lib/__tests__/near-safety.test.ts b/apps/dapp/lib/__tests__/near-safety.test.ts
new file mode 100644
index 00000000..b4f1ecca
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-safety.test.ts
@@ -0,0 +1,340 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ assertMetapoolSafe,
+ assertOneClickSafe,
+ assertRnearSafe,
+ STORAGE_DEPOSIT_YOCTO,
+} from '../chains/near/safety'
+import { METAPOOL_CONTRACT, RNEAR_CONTRACT, USDC_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+import { ChainActionRefused, type NearTransaction, type SigningPayload } from '../chains/types'
+
+/**
+ * A NEAR wallet sends what it signs, so these checks get no second chance. Each
+ * test starts from a payload the app really builds and tampers with one thing.
+ */
+
+const ME = 'alice.near'
+const DEPOSIT = 'dc2c3034e3ebefa1af9a2c419f896d7b761a056244099eefcd09aeb7efc1b86d'
+const OTHER_DEPOSIT = '30ea8641492a90c05b53a337b51d66ed05118eaa9065bb7bb032bb2cea75e034'
+const TG30 = '30000000000000'
+const ONE_NEAR = '1000000000000000000000000'
+
+type Action = NearTransaction['actions'][number]
+
+function call(
+ methodName: string,
+ args: Record,
+ deposit: string,
+ gas = TG30
+): Action {
+ return { type: 'FunctionCall', params: { methodName, args, gas, deposit } }
+}
+const storageFor = (account: string): Action =>
+ call('storage_deposit', { account_id: account, registration_only: true }, STORAGE_DEPOSIT_YOCTO)
+const transfer = (amount = '1000000', to = DEPOSIT): Action =>
+ call('ft_transfer', { receiver_id: to, amount }, '1')
+const stake = (deposit = ONE_NEAR, gas = '100000000000000'): Action =>
+ call('deposit_and_stake', {}, deposit, gas)
+
+function payload(receiverId: string, actions: Action[]): SigningPayload {
+ return { family: 'near', transactions: [{ receiverId, actions }] }
+}
+
+const usdcDeposit = (): SigningPayload => payload(USDC_NEAR, [storageFor(DEPOSIT), transfer()])
+const nearDeposit = (): SigningPayload =>
+ payload(WRAP_NEAR, [
+ storageFor(ME),
+ call('near_deposit', {}, ONE_NEAR),
+ storageFor(DEPOSIT),
+ transfer(ONE_NEAR),
+ ])
+
+function refused(fn: () => void): void {
+ expect(fn).toThrow(ChainActionRefused)
+}
+
+describe('assertOneClickSafe accepts what the app builds', () => {
+ it('passes a stablecoin deposit, with and without a storage payment', () => {
+ expect(() => assertOneClickSafe(usdcDeposit(), ME)).not.toThrow()
+ expect(() => assertOneClickSafe(payload(USDC_NEAR, [transfer()]), ME)).not.toThrow()
+ })
+
+ it('passes a wrap and deposit, with and without registering the sender', () => {
+ expect(() => assertOneClickSafe(nearDeposit(), ME)).not.toThrow()
+ expect(() =>
+ assertOneClickSafe(
+ payload(WRAP_NEAR, [call('near_deposit', {}, ONE_NEAR), transfer(ONE_NEAR)]),
+ ME
+ )
+ ).not.toThrow()
+ })
+})
+
+describe('assertOneClickSafe refuses tampering', () => {
+ it('refuses another family', () => {
+ refused(() => assertOneClickSafe({ family: 'solana', transaction: 'AA==' }, ME))
+ })
+
+ it('refuses a foreign receiver contract', () => {
+ refused(() => assertOneClickSafe(payload('evil.near', [transfer()]), ME))
+ refused(() => assertOneClickSafe(payload('usdt.tether-token.near.evil', [transfer()]), ME))
+ refused(() => assertOneClickSafe(payload(RNEAR_CONTRACT, [transfer()]), ME))
+ })
+
+ it('refuses a transfer to anything but a 64-hex deposit address', () => {
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [transfer('1000000', 'evil.near')]), ME))
+ refused(() =>
+ assertOneClickSafe(payload(USDC_NEAR, [transfer('1000000', DEPOSIT.toUpperCase())]), ME)
+ )
+ refused(() =>
+ assertOneClickSafe(payload(USDC_NEAR, [transfer('1000000', DEPOSIT.slice(1))]), ME)
+ )
+ })
+
+ it('refuses paying your own account as if it were a deposit', () => {
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [transfer('1', DEPOSIT)]), DEPOSIT))
+ })
+
+ it('refuses an extra action: a second transfer, before or after', () => {
+ refused(() =>
+ assertOneClickSafe(payload(USDC_NEAR, [transfer(), transfer('5', OTHER_DEPOSIT)]), ME)
+ )
+ refused(() =>
+ assertOneClickSafe(payload(USDC_NEAR, [transfer('5', OTHER_DEPOSIT), transfer()]), ME)
+ )
+ })
+
+ it('refuses an extra transaction', () => {
+ const [first] = (usdcDeposit() as { transactions: NearTransaction[] }).transactions
+ refused(() =>
+ assertOneClickSafe(
+ { family: 'near', transactions: [first as NearTransaction, first as NearTransaction] },
+ ME
+ )
+ )
+ refused(() => assertOneClickSafe({ family: 'near', transactions: [] }, ME))
+ })
+
+ it('refuses a foreign method, including the ones that do move money', () => {
+ for (const method of [
+ 'ft_transfer_call',
+ 'near_withdraw',
+ 'storage_withdraw',
+ 'storage_unregister',
+ 'add_full_access_key',
+ ]) {
+ refused(() =>
+ assertOneClickSafe(
+ payload(USDC_NEAR, [call(method, { receiver_id: DEPOSIT, amount: '1' }, '1')]),
+ ME
+ )
+ )
+ refused(() =>
+ assertOneClickSafe(
+ payload(USDC_NEAR, [
+ call(method, { receiver_id: DEPOSIT, amount: '1' }, '1'),
+ transfer(),
+ ]),
+ ME
+ )
+ )
+ }
+ })
+
+ it('refuses anything but exactly 1 yoctoNEAR on the transfer', () => {
+ for (const deposit of ['0', '2', ONE_NEAR]) {
+ const tampered = call('ft_transfer', { receiver_id: DEPOSIT, amount: '1000000' }, deposit)
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [tampered]), ME))
+ }
+ })
+
+ it('refuses extra or missing transfer arguments', () => {
+ const memo = call('ft_transfer', { receiver_id: DEPOSIT, amount: '1', memo: 'x' }, '1')
+ const noAmount = call('ft_transfer', { receiver_id: DEPOSIT }, '1')
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [memo]), ME))
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [noAmount]), ME))
+ })
+
+ it('refuses a zero, malformed or oversized transfer', () => {
+ 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))
+ })
+
+ it('refuses a storage payment of the wrong size or for a stranger', () => {
+ const big = call('storage_deposit', { account_id: DEPOSIT, registration_only: true }, ONE_NEAR)
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [big, transfer()]), ME))
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [storageFor('evil.near'), transfer()]), ME))
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [storageFor(ME), transfer()]), ME))
+ const noFlag = call('storage_deposit', { account_id: DEPOSIT }, STORAGE_DEPOSIT_YOCTO)
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [noFlag, transfer()]), ME))
+ refused(() =>
+ assertOneClickSafe(
+ payload(USDC_NEAR, [storageFor(DEPOSIT), storageFor(DEPOSIT), transfer()]),
+ ME
+ )
+ )
+ })
+
+ it('refuses a wrap that does not match what is sent', () => {
+ refused(() =>
+ assertOneClickSafe(
+ payload(WRAP_NEAR, [
+ call('near_deposit', {}, '2000000000000000000000000'),
+ transfer(ONE_NEAR),
+ ]),
+ ME
+ )
+ )
+ refused(() =>
+ assertOneClickSafe(
+ payload(WRAP_NEAR, [
+ call('near_deposit', {}, ONE_NEAR),
+ call('near_deposit', {}, ONE_NEAR),
+ transfer(ONE_NEAR),
+ ]),
+ ME
+ )
+ )
+ refused(() =>
+ assertOneClickSafe(payload(USDC_NEAR, [call('near_deposit', {}, '1000000'), transfer()]), ME)
+ )
+ refused(() => assertOneClickSafe(payload(WRAP_NEAR, [transfer(ONE_NEAR)]), ME))
+ })
+
+ it('refuses more gas than the limits', () => {
+ const heavy = call(
+ 'ft_transfer',
+ { receiver_id: DEPOSIT, amount: '1000000' },
+ '1',
+ '30000000000001'
+ )
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [heavy]), ME))
+ const noGas = call('ft_transfer', { receiver_id: DEPOSIT, amount: '1' }, '1', '0')
+ refused(() => assertOneClickSafe(payload(USDC_NEAR, [noGas]), ME))
+ })
+
+ it('refuses malformed shapes rather than crashing past them', () => {
+ const cases: unknown[] = [
+ { family: 'near' },
+ { family: 'near', transactions: [null] },
+ { family: 'near', transactions: [{ receiverId: USDC_NEAR }] },
+ { family: 'near', transactions: [{ receiverId: USDC_NEAR, actions: [] }] },
+ {
+ family: 'near',
+ transactions: [{ receiverId: USDC_NEAR, actions: [{ type: 'Transfer' }] }],
+ },
+ {
+ family: 'near',
+ transactions: [{ receiverId: USDC_NEAR, actions: [{ type: 'FunctionCall' }] }],
+ },
+ {
+ family: 'near',
+ transactions: [{ receiverId: USDC_NEAR, signerId: 'evil.near', actions: [transfer()] }],
+ },
+ {
+ family: 'near',
+ transactions: [
+ {
+ receiverId: USDC_NEAR,
+ actions: [
+ {
+ type: 'FunctionCall',
+ params: {
+ methodName: 'ft_transfer',
+ args: { receiver_id: DEPOSIT, amount: '1' },
+ gas: 30000000000000,
+ deposit: '1',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ]
+ for (const each of cases) {
+ refused(() => assertOneClickSafe(each as SigningPayload, ME))
+ }
+ })
+
+ it('refuses a signer that is not a NEAR account', () => {
+ refused(() =>
+ assertOneClickSafe(usdcDeposit(), 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA')
+ )
+ })
+})
+
+describe('the staking checks', () => {
+ it('accepts what each venue builds', () => {
+ expect(() =>
+ assertRnearSafe(
+ payload(RNEAR_CONTRACT, [storageFor(ME), stake(ONE_NEAR, '200000000000000')]),
+ ME
+ )
+ ).not.toThrow()
+ expect(() =>
+ assertRnearSafe(payload(RNEAR_CONTRACT, [stake(ONE_NEAR, '200000000000000')]), ME)
+ ).not.toThrow()
+ expect(() => assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake()]), ME)).not.toThrow()
+ })
+
+ it('refuses the other venue, or any foreign receiver', () => {
+ refused(() => assertRnearSafe(payload(METAPOOL_CONTRACT, [stake()]), ME))
+ refused(() => assertMetapoolSafe(payload(RNEAR_CONTRACT, [stake()]), ME))
+ refused(() => assertMetapoolSafe(payload('evil.near', [stake()]), ME))
+ refused(() => assertMetapoolSafe(payload(USDC_NEAR, [stake()]), ME))
+ })
+
+ it('refuses a foreign method', () => {
+ for (const method of [
+ 'deposit',
+ 'unstake_all',
+ 'withdraw_all',
+ 'ft_transfer',
+ 'ft_transfer_call',
+ ]) {
+ refused(() =>
+ assertMetapoolSafe(payload(METAPOOL_CONTRACT, [call(method, {}, ONE_NEAR)]), ME)
+ )
+ refused(() =>
+ assertMetapoolSafe(payload(METAPOOL_CONTRACT, [call(method, {}, '1'), stake()]), ME)
+ )
+ }
+ })
+
+ it('refuses an extra action', () => {
+ refused(() => assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake(), stake()]), ME))
+ refused(() => assertMetapoolSafe(payload(METAPOOL_CONTRACT, [storageFor(ME), stake()]), ME))
+ refused(() =>
+ assertRnearSafe(payload(RNEAR_CONTRACT, [storageFor(ME), storageFor(ME), stake()]), ME)
+ )
+ })
+
+ it('refuses a stake of nothing, or beyond the ceiling of 1,000 NEAR', () => {
+ refused(() => assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('0')]), ME))
+ refused(() =>
+ assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('1000000000000000000000000001')]), ME)
+ )
+ expect(() =>
+ assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake('1000000000000000000000000000')]), ME)
+ ).not.toThrow()
+ })
+
+ it('refuses arguments on the stake, a stranger storage payment and excess gas', () => {
+ refused(() =>
+ assertMetapoolSafe(
+ payload(METAPOOL_CONTRACT, [call('deposit_and_stake', { x: 1 }, ONE_NEAR)]),
+ ME
+ )
+ )
+ refused(() => assertRnearSafe(payload(RNEAR_CONTRACT, [storageFor('evil.near'), stake()]), ME))
+ refused(() =>
+ assertMetapoolSafe(payload(METAPOOL_CONTRACT, [stake(ONE_NEAR, '100000000000001')]), ME)
+ )
+ refused(() =>
+ assertRnearSafe(payload(RNEAR_CONTRACT, [stake(ONE_NEAR, '200000000000001')]), ME)
+ )
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-signature.test.ts b/apps/dapp/lib/__tests__/near-signature.test.ts
new file mode 100644
index 00000000..0644fa87
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-signature.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from 'vitest'
+
+import { verifyQuoteSignature, type SignedQuoteFields } from '../chains/near/quote-signature'
+
+/**
+ * Real responses from 1Click, captured 2026-11-03: one dry quote and one quote
+ * with a deposit address that was never paid. They exercise the real manager key
+ * with no network, so a mistake in the hashing or the encoding shows up here.
+ */
+
+const DRY: SignedQuoteFields = {
+ quote: {
+ amountIn: '1000000',
+ amountInFormatted: '1.0',
+ amountInUsd: '0.999387000000',
+ minAmountIn: '1000000',
+ amountOut: '260088790550469068128425',
+ amountOutFormatted: '0.260088790550469068128425',
+ amountOutUsd: '0.998740955714',
+ minAmountOut: '257487902644964377447140',
+ timeEstimate: 22,
+ refundFee: '0',
+ withdrawFee: '0',
+ },
+ quoteRequest: {
+ dry: true,
+ depositMode: 'SIMPLE',
+ swapType: 'EXACT_INPUT',
+ slippageTolerance: 100,
+ originAsset: 'nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1',
+ depositType: 'ORIGIN_CHAIN',
+ destinationAsset: 'nep141:wrap.near',
+ amount: '1000000',
+ refundTo: 'root.near',
+ refundType: 'ORIGIN_CHAIN',
+ recipient: 'root.near',
+ recipientType: 'DESTINATION_CHAIN',
+ deadline: '2026-11-03T15:46:32.596Z',
+ confidentiality: 'public',
+ quoteWaitingTimeMs: 0,
+ insured: false,
+ appFees: [
+ { recipient: '5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd', fee: 20 },
+ ],
+ },
+ signature:
+ 'ed25519:2FydMpooD6GNzdzXUgnijmFKYyEwbmmR3DRtWNwtuwCieN1RWp3wziSVYwW33n8Q2WjkogQJv93BAp651NKfsaSJ',
+ timestamp: '2026-11-03T15:36:33.085Z',
+}
+
+const LIVE: SignedQuoteFields = {
+ quote: {
+ amountIn: '1000000',
+ amountInFormatted: '1.0',
+ amountInUsd: '0.999460000000',
+ minAmountIn: '1000000',
+ amountOut: '259276647566704918032786',
+ amountOutFormatted: '0.259276647566704918032786',
+ amountOutUsd: '0.993029560180',
+ minAmountOut: '256683881091037868852458',
+ timeEstimate: 22,
+ refundFee: '0',
+ withdrawFee: '0',
+ deadline: '2026-11-06T15:45:35.601Z',
+ timeWhenInactive: '2026-11-06T15:45:35.601Z',
+ depositAddress: '570846f7a07a97b9640a437e306a2a26d2524ca9d4f9a9016a434bc1c47d6f74',
+ },
+ quoteRequest: {
+ dry: false,
+ depositMode: 'SIMPLE',
+ swapType: 'EXACT_INPUT',
+ slippageTolerance: 100,
+ originAsset: 'nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1',
+ depositType: 'ORIGIN_CHAIN',
+ destinationAsset: 'nep141:wrap.near',
+ amount: '1000000',
+ refundTo: 'root.near',
+ refundType: 'ORIGIN_CHAIN',
+ recipient: 'root.near',
+ recipientType: 'DESTINATION_CHAIN',
+ deadline: '2026-11-03T15:45:35.601Z',
+ confidentiality: 'public',
+ quoteWaitingTimeMs: 0,
+ insured: false,
+ appFees: [
+ {
+ recipient: '5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd',
+ fee: 20,
+ limitOrderId: null,
+ },
+ ],
+ },
+ signature:
+ 'ed25519:3dT8WCUbD8dhCfzWoyMsySRp45eofsujSxipDHvxkSrGxmYj6gVX4Nvsvy2twg9xzHmWRQNf1goNkyqFywycXvQ1',
+ timestamp: '2026-11-03T15:40:36.238Z',
+}
+
+function tamperedQuote(base: SignedQuoteFields, field: string, value: unknown): SignedQuoteFields {
+ return { ...base, quote: { ...base.quote, [field]: value } }
+}
+
+function tamperedRequest(
+ base: SignedQuoteFields,
+ field: string,
+ value: unknown
+): SignedQuoteFields {
+ return { ...base, quoteRequest: { ...base.quoteRequest, [field]: value } }
+}
+
+describe('verifyQuoteSignature', () => {
+ it('accepts real quotes from 1Click', () => {
+ expect(verifyQuoteSignature(DRY)).toBe(true)
+ expect(verifyQuoteSignature(LIVE)).toBe(true)
+ })
+
+ it('refuses a swapped deposit address', () => {
+ const other = '30ea8641492a90c05b53a337b51d66ed05118eaa9065bb7bb032bb2cea75e034'
+ expect(verifyQuoteSignature(tamperedQuote(LIVE, 'depositAddress', other))).toBe(false)
+ })
+
+ it('refuses changed amounts', () => {
+ expect(verifyQuoteSignature(tamperedQuote(DRY, 'amountOut', '999'))).toBe(false)
+ expect(verifyQuoteSignature(tamperedRequest(LIVE, 'amount', '999'))).toBe(false)
+ })
+
+ it('refuses a changed recipient or refund address', () => {
+ expect(verifyQuoteSignature(tamperedRequest(LIVE, 'recipient', 'evil.near'))).toBe(false)
+ expect(verifyQuoteSignature(tamperedRequest(LIVE, 'refundTo', 'evil.near'))).toBe(false)
+ })
+
+ it('refuses a changed timestamp, a bad signature and another key', () => {
+ expect(verifyQuoteSignature({ ...DRY, timestamp: '2026-11-03T15:36:34.085Z' })).toBe(false)
+ expect(verifyQuoteSignature({ ...DRY, signature: 'ed25519:notasignature0OIl' })).toBe(false)
+ expect(verifyQuoteSignature({ ...DRY, signature: LIVE.signature })).toBe(false)
+ expect(verifyQuoteSignature(DRY, 'CuoEPFoaHBWRnhxtmKZEHxkXhFJSTKyrPTgCFUgRmPfQ')).toBe(false)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-stake.test.ts b/apps/dapp/lib/__tests__/near-stake.test.ts
new file mode 100644
index 00000000..74d8049d
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-stake.test.ts
@@ -0,0 +1,320 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { nearModule } from '../chains/near/module'
+import { confirmStake, metapoolVenue, rnearVenue } from '../chains/near/stake'
+import { METAPOOL_CONTRACT, RNEAR_CONTRACT } from '../chains/near/tokens'
+import { prepareChainAction } from '../chains/prepare'
+import { ChainActionRefused, type ChainActionRequest, type NearTransaction } from '../chains/types'
+
+const ME = 'alice.near'
+const HASH = 'G6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScA'
+
+interface Rpc {
+ views: Record
+ down: boolean
+ tx: (() => unknown) | undefined
+ apy: unknown
+ apyDown: boolean
+}
+
+let rpc: Rpc
+
+function install(): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: string | URL, init?: RequestInit) => {
+ const url = String(input)
+ const json = (data: unknown): Response => new Response(JSON.stringify(data))
+ if (url.endsWith('/get-rnear-apy')) {
+ if (rpc.apyDown) throw new Error('down')
+ return json({ code: 0, data: rpc.apy })
+ }
+ if (url.endsWith('/v0/tokens')) {
+ return json([
+ {
+ assetId: 'nep141:wrap.near',
+ decimals: 24,
+ symbol: 'wNEAR',
+ price: 4,
+ priceUpdatedAt: new Date().toISOString(),
+ },
+ ])
+ }
+ 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 }
+ }
+ if (body.method === 'tx') {
+ const result = rpc.tx?.()
+ return json(
+ result === undefined ? { error: { cause: { name: 'UNKNOWN_TRANSACTION' } } } : { result }
+ )
+ }
+ const key = `${body.params.account_id}.${body.params.method_name}`
+ if (!(key in rpc.views)) return json({ result: { error: `no ${key}` } })
+ return json({ result: { result: [...Buffer.from(JSON.stringify(rpc.views[key]))] } })
+ })
+ )
+}
+
+const stake = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'stake',
+ amount: '2',
+ tokenIn: 'NEAR',
+ ...over,
+})
+
+function txOf(prepared: { signing: unknown }): NearTransaction {
+ return (prepared.signing as { transactions: NearTransaction[] })
+ .transactions[0] as NearTransaction
+}
+
+beforeEach(() => {
+ rpc = {
+ views: {
+ [`${RNEAR_CONTRACT}.ft_price`]: '1000000000000000000000000',
+ [`${RNEAR_CONTRACT}.is_paused`]: false,
+ [`${RNEAR_CONTRACT}.storage_balance_of`]: null,
+ [`${METAPOOL_CONTRACT}.get_st_near_price`]: '2000000000000000000000000',
+ [`${METAPOOL_CONTRACT}.get_contract_params`]: {
+ min_deposit_amount: '1000000000000000000000000',
+ },
+ },
+ down: false,
+ tx: undefined,
+ apy: '4.588660',
+ apyDown: false,
+ }
+ install()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+})
+
+describe('staking with rNEAR', () => {
+ it('registers a new staker, then stakes, and passes its own check', async () => {
+ const prepared = await rnearVenue.prepare(stake(), ME)
+ const tx = txOf(prepared)
+ expect(tx.receiverId).toBe('lst.rhealab.near')
+ expect(tx.actions.map((a) => a.params.methodName)).toEqual([
+ 'storage_deposit',
+ 'deposit_and_stake',
+ ])
+ expect(tx.actions[1]?.params).toMatchObject({
+ args: {},
+ deposit: '2000000000000000000000000',
+ gas: '200000000000000',
+ })
+ expect(prepared.receive).toEqual({ amount: '2', symbol: 'rNEAR', approximate: true })
+ expect(() => rnearVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('skips registration when already registered', async () => {
+ rpc.views[`${RNEAR_CONTRACT}.storage_balance_of`] = { total: '1', available: '0' }
+ const prepared = await rnearVenue.prepare(stake(), ME)
+ expect(txOf(prepared).actions.map((a) => a.params.methodName)).toEqual(['deposit_and_stake'])
+ expect(prepared.notes.join(' ')).not.toMatch(/register/)
+ })
+
+ it('shows the rate from Rhea, and no rate if that fails', async () => {
+ expect((await rnearVenue.prepare(stake(), ME)).apy).toEqual({ basePercent: 4.58866 })
+ rpc.apyDown = true
+ expect((await rnearVenue.prepare(stake(), ME)).apy).toBeUndefined()
+ rpc.apyDown = false
+ for (const bad of ['0', 'NaN', 'high', 5000, null]) {
+ rpc.apy = bad
+ expect((await rnearVenue.prepare(stake(), ME)).apy).toBeUndefined()
+ }
+ })
+
+ it('names the link to Rhea Lend and promises no return', async () => {
+ const notes = (await rnearVenue.prepare(stake(), ME)).notes.join(' ')
+ expect(notes).toMatch(/Rhea Lend/)
+ expect(notes).toMatch(/no return is guaranteed/)
+ })
+
+ it('refuses while paused, or when NEAR cannot be read', async () => {
+ rpc.views[`${RNEAR_CONTRACT}.is_paused`] = true
+ await expect(rnearVenue.prepare(stake(), ME)).rejects.toThrow(/paused/)
+ rpc.down = true
+ await expect(rnearVenue.prepare(stake(), ME)).rejects.toThrow(/could not be read/)
+ })
+
+ it('refuses a zero price rather than dividing by it', async () => {
+ rpc.views[`${RNEAR_CONTRACT}.ft_price`] = '0'
+ await expect(rnearVenue.prepare(stake(), ME)).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('staking with Meta Pool', () => {
+ it('sends one call, with no storage step, and passes its own check', async () => {
+ const prepared = await metapoolVenue.prepare(stake(), ME)
+ const tx = txOf(prepared)
+ expect(tx.receiverId).toBe('meta-pool.near')
+ expect(tx.actions).toEqual([
+ {
+ type: 'FunctionCall',
+ params: {
+ methodName: 'deposit_and_stake',
+ args: {},
+ gas: '100000000000000',
+ deposit: '2000000000000000000000000',
+ },
+ },
+ ])
+ expect(prepared.receive).toEqual({ amount: '1', symbol: 'stNEAR', approximate: true })
+ expect(prepared.apy).toBeUndefined()
+ expect(() => metapoolVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('refuses below the minimum the contract reports', async () => {
+ await expect(metapoolVenue.prepare(stake({ amount: '0.5' }), ME)).rejects.toThrow(
+ /at least 1 NEAR/
+ )
+ })
+
+ it('refuses when NEAR cannot be read', async () => {
+ rpc.down = true
+ await expect(metapoolVenue.prepare(stake(), ME)).rejects.toThrow(/could not be read/)
+ })
+})
+
+describe('what both stake venues refuse', () => {
+ it('refuses anything but NEAR, and any other kind of action', async () => {
+ for (const venue of [rnearVenue, metapoolVenue]) {
+ await expect(venue.prepare(stake({ tokenIn: 'USDC' }), ME)).rejects.toThrow(
+ /only stakes NEAR/
+ )
+ await expect(venue.prepare(stake({ kind: 'swap' }), ME)).rejects.toThrow(ChainActionRefused)
+ await expect(
+ venue.prepare(stake(), 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA')
+ ).rejects.toThrow(/not a NEAR account/)
+ }
+ })
+})
+
+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({
+ venueId: 'metapool',
+ })
+ await expect(
+ prepareChainAction(nearModule, stake({ venue: 'rNEAR' }), ME, env)
+ ).resolves.toMatchObject({ venueId: 'rnear' })
+ await expect(
+ prepareChainAction(nearModule, stake({ venue: 'meta pool' }), ME, env)
+ ).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/
+ )
+ 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([
+ ['NEAR', 'wrap.near', 24],
+ ['USDC', '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1', 6],
+ ['USDT', 'usdt.tether-token.near', 6],
+ ])
+ })
+})
+
+describe('confirming a stake', () => {
+ const executed =
+ (status: unknown, over: Record = {}): (() => unknown) =>
+ () => ({
+ status,
+ transaction: {
+ signer_id: ME,
+ receiver_id: METAPOOL_CONTRACT,
+ actions: [
+ { FunctionCall: { method_name: 'deposit_and_stake', args: 'e30=', deposit: '1' } },
+ ],
+ ...over,
+ },
+ })
+
+ async function run(over: { venueId?: string; hashes?: string[] } = {}) {
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] })
+ const pending = confirmStake({
+ account: ME,
+ venueId: over.venueId ?? 'metapool',
+ signed: { family: 'near', hashes: over.hashes ?? [HASH] },
+ })
+ await vi.runAllTimersAsync()
+ return pending
+ }
+
+ it('returns the hash and explorer link when the transaction succeeded', async () => {
+ rpc.tx = executed({ SuccessValue: '' })
+ await expect(run()).resolves.toEqual({
+ ok: true,
+ hash: HASH,
+ explorerUrl: `https://nearblocks.io/txns/${HASH}`,
+ })
+ })
+
+ it('says it failed in a plain sentence', async () => {
+ rpc.tx = executed({ Failure: {} })
+ const result = await run()
+ expect(!result.ok && result.error).toMatch(/failed on NEAR.*came back/)
+ })
+
+ it('keeps polling through unknown, then succeeds', async () => {
+ let seen = 0
+ const done = executed({ SuccessValue: '' })
+ rpc.tx = () => {
+ seen += 1
+ return seen < 3 ? undefined : done()
+ }
+ await expect(run()).resolves.toMatchObject({ ok: true })
+ expect(seen).toBe(3)
+ })
+
+ it('reads an unfinished status as not yet confirmed', async () => {
+ rpc.tx = executed('Started')
+ const result = await run()
+ expect(!result.ok && result.error).toMatch(/hasn't been confirmed yet.*Don't send it again/)
+ })
+
+ it('stops inside its bound and says so when never confirmed', async () => {
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] })
+ const startedAt = Date.now()
+ const pending = confirmStake({
+ account: ME,
+ venueId: 'metapool',
+ signed: { family: 'near', hashes: [HASH] },
+ })
+ await vi.runAllTimersAsync()
+ const result = await pending
+ expect(Date.now() - startedAt).toBeLessThan(30_000)
+ expect(result.ok).toBe(false)
+ })
+
+ it('refuses a hash from another signer, another contract or another method', async () => {
+ rpc.tx = executed({ SuccessValue: '' }, { signer_id: 'someone.near' })
+ expect(await run()).toMatchObject({ ok: false })
+ rpc.tx = executed({ SuccessValue: '' }, { receiver_id: 'evil.near' })
+ expect(await run()).toMatchObject({ ok: false })
+ rpc.tx = executed({ SuccessValue: '' }, { actions: [] })
+ expect(await run()).toMatchObject({ ok: false })
+ rpc.tx = executed({ SuccessValue: '' })
+ expect(await run({ venueId: 'rnear' })).toMatchObject({ ok: false })
+ })
+
+ it('refuses an unknown venue and bad hashes', async () => {
+ expect(await run({ venueId: 'constructor' })).toMatchObject({ ok: false })
+ expect(await run({ hashes: ['not a hash'] })).toMatchObject({ ok: false })
+ expect(await run({ hashes: [] })).toMatchObject({ ok: false })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-swap.test.ts b/apps/dapp/lib/__tests__/near-swap.test.ts
new file mode 100644
index 00000000..bfd4beab
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-swap.test.ts
@@ -0,0 +1,728 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { clearTokenCache } from '../chains/near/oneclick'
+import { nearModule } from '../chains/near/module'
+import { oneClickVenue, submitOneClick } from '../chains/near/swap'
+import { USDC_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+import { prepareChainAction } from '../chains/prepare'
+import { ChainActionRefused, type ChainActionRequest, type NearTransaction } from '../chains/types'
+
+vi.mock('../chains/near/quote-signature', () => ({
+ verifyQuoteSignature: vi.fn(() => true),
+}))
+
+import { verifyQuoteSignature } from '../chains/near/quote-signature'
+
+const ME = 'alice.near'
+const DEPOSIT = 'dc2c3034e3ebefa1af9a2c419f896d7b761a056244099eefcd09aeb7efc1b86d'
+const HASH = 'G6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScA'
+
+type Json = Record
+
+interface Sim {
+ /** Each quote request, in order. */
+ quotes: Json[]
+ quoteHeaders: Record[]
+ submits: Json[]
+ registered: Set
+ quoteOverride: (body: Json) => { status?: number; json?: unknown } | Json | undefined
+ statuses: (string | undefined)[]
+ statusRecipient: string
+ tx: (() => unknown) | undefined
+}
+
+function tokenList(priceUpdatedAt = new Date().toISOString()): Json[] {
+ return [
+ { assetId: 'nep141:wrap.near', decimals: 24, symbol: 'wNEAR', price: 4, priceUpdatedAt },
+ { assetId: `nep141:${USDC_NEAR}`, decimals: 6, symbol: 'USDC', price: 1, priceUpdatedAt },
+ {
+ assetId: 'nep141:usdt.tether-token.near',
+ decimals: 6,
+ symbol: 'USDT',
+ price: 1,
+ priceUpdatedAt,
+ },
+ {
+ assetId: 'nep141:miden-0x5cb79d29507d3ad17bb6dc60279e89.omft.near',
+ decimals: 6,
+ symbol: 'USDT',
+ price: 0.5,
+ priceUpdatedAt,
+ },
+ ]
+}
+
+function goodQuote(body: Json): Json {
+ const toNear = body['destinationAsset'] === 'nep141:wrap.near'
+ const fromNear = body['originAsset'] === 'nep141:wrap.near'
+ const amount = body['amount'] as string
+ const inDollars = fromNear ? (Number(amount) / 1e24) * 4 : Number(amount) / 1e6
+ const outDollars = inDollars * 0.995
+ const out = toNear
+ ? BigInt(Math.round((outDollars / 4) * 1e6)) * BigInt('1000000000000000000')
+ : BigInt(Math.round(outDollars * 1e6))
+ return {
+ quote: {
+ amountIn: amount,
+ amountInFormatted: '1',
+ amountInUsd: String(inDollars),
+ minAmountIn: amount,
+ amountOut: out.toString(),
+ amountOutFormatted: 'x',
+ amountOutUsd: String(outDollars),
+ minAmountOut: ((out * BigInt(99)) / BigInt(100)).toString(),
+ timeEstimate: 20,
+ refundFee: '0',
+ withdrawFee: '0',
+ ...(body['dry'] === true
+ ? {}
+ : {
+ depositAddress: DEPOSIT,
+ deadline: '2026-11-06T00:00:00.000Z',
+ timeWhenInactive: '2026-11-06T00:00:00.000Z',
+ }),
+ },
+ quoteRequest: { ...body, appFees: [{ recipient: 'fee.near', fee: 20 }] },
+ signature: 'ed25519:x',
+ timestamp: '2026-11-03T00:00:00.000Z',
+ }
+}
+
+function installFetch(sim: Sim): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: string | URL, init?: RequestInit) => {
+ const url = String(input)
+ const json = (data: unknown, status = 200): Response =>
+ new Response(JSON.stringify(data), { status })
+ if (url.endsWith('/v0/tokens')) return json(tokenList())
+ if (url.endsWith('/v0/quote')) {
+ const body = JSON.parse(init?.body as string) as Json
+ sim.quotes.push(body)
+ sim.quoteHeaders.push(init?.headers as Record)
+ const override = sim.quoteOverride(body)
+ if (override !== undefined && 'status' in override) {
+ return json(override['json'], override['status'] as number)
+ }
+ return json(override ?? goodQuote(body), 201)
+ }
+ if (url.endsWith('/v0/deposit/submit')) {
+ sim.submits.push(JSON.parse(init?.body as string) as Json)
+ return json({ correlationId: 'c' })
+ }
+ if (url.includes('/v0/status')) {
+ const status = sim.statuses.length > 1 ? sim.statuses.shift() : sim.statuses[0]
+ return json({
+ status,
+ quoteResponse: { quoteRequest: { recipient: sim.statusRecipient } },
+ })
+ }
+ // Everything else is the NEAR RPC.
+ const rpc = JSON.parse(init?.body as string) as {
+ method: string
+ params: { method_name?: string; account_id?: string; args_base64?: string }
+ }
+ if (rpc.method === 'tx') {
+ return json({
+ result: sim.tx?.() ?? null,
+ error: sim.tx === undefined ? { cause: { name: 'UNKNOWN_TRANSACTION' } } : undefined,
+ })
+ }
+ const args = JSON.parse(Buffer.from(rpc.params.args_base64 ?? '', 'base64').toString()) as {
+ account_id: string
+ }
+ const isIn = sim.registered.has(`${rpc.params.account_id}|${args.account_id}`)
+ const bytes = [...Buffer.from(JSON.stringify(isIn ? { total: '1', available: '0' } : null))]
+ return json({ result: { result: bytes } })
+ })
+ )
+}
+
+function newSim(): Sim {
+ return {
+ quotes: [],
+ quoteHeaders: [],
+ submits: [],
+ registered: new Set(),
+ quoteOverride: () => undefined,
+ statuses: ['SUCCESS'],
+ statusRecipient: ME,
+ tx: undefined,
+ }
+}
+
+const swap = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'swap',
+ amount: '1',
+ tokenIn: 'USDC',
+ tokenOut: 'NEAR',
+ ...over,
+})
+
+function txOf(prepared: { signing: unknown }): NearTransaction {
+ return (prepared.signing as { transactions: NearTransaction[] })
+ .transactions[0] as NearTransaction
+}
+
+let sim: Sim
+
+beforeEach(() => {
+ clearTokenCache()
+ sim = newSim()
+ installFetch(sim)
+ vi.mocked(verifyQuoteSignature).mockReturnValue(true)
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
+ vi.useRealTimers()
+})
+
+describe('preparing a 1Click swap', () => {
+ it('previews with a dry quote, then asks for the real one with the required fields', async () => {
+ await oneClickVenue.prepare(swap(), ME)
+ expect(sim.quotes.map((q) => q['dry'])).toEqual([true, false])
+ for (const quote of sim.quotes) {
+ expect(quote).toMatchObject({
+ swapType: 'EXACT_INPUT',
+ depositType: 'ORIGIN_CHAIN',
+ originAsset: `nep141:${USDC_NEAR}`,
+ destinationAsset: 'nep141:wrap.near',
+ amount: '1000000',
+ refundTo: ME,
+ refundType: 'ORIGIN_CHAIN',
+ recipient: ME,
+ recipientType: 'DESTINATION_CHAIN',
+ slippageTolerance: 100,
+ })
+ const minutes = (Date.parse(quote['deadline'] as string) - Date.now()) / 60_000
+ expect(minutes).toBeGreaterThan(3)
+ expect(minutes).toBeLessThanOrEqual(5)
+ }
+ })
+
+ it('builds a storage payment and transfer for a stablecoin, which passes assertSafe', async () => {
+ const prepared = await oneClickVenue.prepare(swap(), ME)
+ const tx = txOf(prepared)
+ expect(tx.receiverId).toBe(USDC_NEAR)
+ expect(tx.actions.map((a) => a.params.methodName)).toEqual(['storage_deposit', 'ft_transfer'])
+ expect(tx.actions[1]?.params).toMatchObject({
+ args: { receiver_id: DEPOSIT, amount: '1000000' },
+ deposit: '1',
+ })
+ expect(prepared.context?.['depositAddress']).toBe(DEPOSIT)
+ expect(() => oneClickVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('skips the storage payment when the deposit address is already registered', async () => {
+ sim.registered.add(`${USDC_NEAR}|${DEPOSIT}`)
+ const tx = txOf(await oneClickVenue.prepare(swap(), ME))
+ expect(tx.actions.map((a) => a.params.methodName)).toEqual(['ft_transfer'])
+ })
+
+ it('wraps NEAR first, and registers an unregistered sender before wrapping', async () => {
+ const request = swap({ tokenIn: 'NEAR', tokenOut: 'USDC' })
+ const prepared = await oneClickVenue.prepare(request, ME)
+ const tx = txOf(prepared)
+ expect(tx.receiverId).toBe(WRAP_NEAR)
+ expect(tx.actions.map((a) => a.params.methodName)).toEqual([
+ 'storage_deposit',
+ 'near_deposit',
+ 'storage_deposit',
+ 'ft_transfer',
+ ])
+ expect(tx.actions[0]?.params.args).toMatchObject({ account_id: ME })
+ expect(tx.actions[1]?.params.deposit).toBe('1000000000000000000000000')
+ expect(tx.actions[2]?.params.args).toMatchObject({ account_id: DEPOSIT })
+ expect(() => oneClickVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('does not register a sender who is already registered on wrap.near', async () => {
+ sim.registered.add(`${WRAP_NEAR}|${ME}`)
+ const prepared = await oneClickVenue.prepare(swap({ tokenIn: 'NEAR', tokenOut: 'USDC' }), ME)
+ expect(txOf(prepared).actions.map((a) => a.params.methodName)).toEqual([
+ 'near_deposit',
+ 'storage_deposit',
+ 'ft_transfer',
+ ])
+ expect(() => oneClickVenue.assertSafe(prepared.signing, ME)).not.toThrow()
+ })
+
+ it('tells the user about mainnet, the fee and the refund address', async () => {
+ const prepared = await oneClickVenue.prepare(swap(), ME)
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/mainnet only/)
+ expect(notes).toMatch(/0\.20% fee/)
+ expect(notes).toContain(ME)
+ expect(prepared.notes.length).toBeLessThanOrEqual(3)
+ expect(prepared.receive?.approximate).toBe(true)
+ expect(notes).not.toMatch(/guarantee/i)
+ })
+
+ it('mentions a withdrawal fee and a refund fee when the quote has them', async () => {
+ sim.quoteOverride = (body) => {
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, withdrawFee: '4807', refundFee: '100' } }
+ }
+ const prepared = await oneClickVenue.prepare(swap({ tokenIn: 'NEAR', tokenOut: 'USDT' }), ME)
+ expect(prepared.notes.join(' ')).toMatch(/register you on that token/)
+ expect(prepared.notes.join(' ')).toMatch(/minus a fee/)
+ })
+
+ it('sends the API key and app fees only when configured', async () => {
+ await oneClickVenue.prepare(swap(), ME)
+ expect(sim.quoteHeaders[0]).not.toHaveProperty('X-API-Key')
+ expect(sim.quotes[0]).not.toHaveProperty('appFees')
+
+ vi.stubEnv('ONECLICK_API_KEY', 'test-key')
+ vi.stubEnv('ONECLICK_APP_FEE_BPS', '30')
+ vi.stubEnv('ONECLICK_APP_FEE_RECIPIENT', 'fees.intent.near')
+ sim.quotes.length = 0
+ sim.quoteHeaders.length = 0
+ await oneClickVenue.prepare(swap(), ME)
+ expect(sim.quoteHeaders[1]).toMatchObject({ 'X-API-Key': 'test-key' })
+ expect(sim.quotes[1]?.['appFees']).toEqual([{ recipient: 'fees.intent.near', fee: 30 }])
+ })
+
+ it('omits app fees rather than charge on a bad setting', async () => {
+ for (const [bps, recipient] of [
+ ['abc', 'fees.intent.near'],
+ ['476', 'fees.intent.near'],
+ ['30', 'NOT VALID'],
+ ['30', ''],
+ ] as const) {
+ vi.stubEnv('ONECLICK_APP_FEE_BPS', bps)
+ vi.stubEnv('ONECLICK_APP_FEE_RECIPIENT', recipient)
+ sim.quotes.length = 0
+ await oneClickVenue.prepare(swap(), ME)
+ expect(sim.quotes[0]).not.toHaveProperty('appFees')
+ }
+ })
+})
+
+describe('what 1Click can refuse', () => {
+ async function refusal(request: ChainActionRequest = swap()): Promise {
+ const error = await oneClickVenue.prepare(request, ME).catch((e: unknown) => e)
+ expect(error).toBeInstanceOf(ChainActionRefused)
+ return (error as ChainActionRefused).message
+ }
+
+ it('says "no liquidity" in a plain sentence', async () => {
+ sim.quoteOverride = () => ({ status: 400, json: { message: 'No liquidity available' } })
+ expect(await refusal()).toMatch(/no price for this right now.*different amount/)
+ expect(sim.quotes).toHaveLength(1)
+ })
+
+ it('reads a temporary minimum, passing on only the amount', async () => {
+ sim.quoteOverride = () => ({
+ status: 400,
+ json: { message: 'Temporary swap limits: minimum swap amount is $1,000 bad ' },
+ })
+ const message = await refusal()
+ expect(message).toMatch(/won't take less than \$1,000/)
+ expect(message).not.toContain('')
+ })
+
+ it('handles other refusals without echoing what came back', async () => {
+ sim.quoteOverride = () => ({ status: 400, json: { message: 'internal stack trace' } })
+ expect(await refusal()).not.toContain('stack trace')
+ sim.quoteOverride = () => ({ status: 401, json: {} })
+ expect(await refusal()).toMatch(/API key/)
+ sim.quoteOverride = () => ({ status: 429, json: {} })
+ expect(await refusal()).toMatch(/limiting requests/)
+ })
+
+ it('refuses when 1Click cannot be reached', async () => {
+ const real = vi.mocked(fetch).getMockImplementation()
+ vi.mocked(fetch).mockImplementation(async (input, init) => {
+ if (String(input).endsWith('/v0/quote')) throw new Error('timed out')
+ return (real as typeof fetch)(input, init)
+ })
+ expect(await refusal()).toMatch(/isn't answering/)
+ })
+
+ it('refuses a dry quote worth over 5% less, and never asks for a deposit address', async () => {
+ sim.quoteOverride = (body) => {
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, amountOutUsd: '0.94' } }
+ }
+ expect(await refusal()).toMatch(/about 6\.0% less/)
+ expect(sim.quotes).toHaveLength(1)
+ })
+
+ it('accepts a quote at just under 5%', async () => {
+ sim.quoteOverride = (body) => {
+ const good = goodQuote(body) as { quote: Json }
+ return {
+ ...good,
+ quote: {
+ ...good.quote,
+ amountOutUsd: '0.951',
+ amountOut: '237750000000000000000000',
+ minAmountOut: '235372500000000000000000',
+ },
+ }
+ }
+ await expect(oneClickVenue.prepare(swap(), ME)).resolves.toBeDefined()
+ })
+
+ it('catches a low output even if the quote claims a good dollar figure', async () => {
+ sim.quoteOverride = (body) => {
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, amountOut: '1', minAmountOut: '0' } }
+ }
+ expect(await refusal()).toMatch(/less than you pay/)
+ })
+
+ it('refuses the real quote too, if the price worsened after the preview', async () => {
+ sim.quoteOverride = (body) => {
+ if (body['dry'] === true) return undefined
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, amountOutUsd: '0.5' } }
+ }
+ expect(await refusal()).toMatch(/less than you pay/)
+ })
+
+ it('refuses a stale price rather than valuing on it', async () => {
+ const real = vi.mocked(fetch).getMockImplementation()
+ vi.mocked(fetch).mockImplementation(async (input, init) => {
+ if (String(input).endsWith('/v0/tokens')) {
+ return new Response(JSON.stringify(tokenList('2026-01-01T00:00:00.000Z')))
+ }
+ return (real as typeof fetch)(input, init)
+ })
+ expect(await refusal()).toMatch(/couldn't be checked/)
+ })
+
+ it('refuses a quote that does not match the request', async () => {
+ for (const [field, value] of [
+ ['recipient', 'evil.near'],
+ ['refundTo', 'evil.near'],
+ ['refundType', 'INTENTS'],
+ ['recipientType', 'INTENTS'],
+ ['amount', '999'],
+ ['originAsset', 'nep141:evil.near'],
+ ['destinationAsset', 'nep141:evil.near'],
+ ['slippageTolerance', 500],
+ ['swapType', 'FLEX_INPUT'],
+ ['deadline', '2030-01-01T00:00:00.000Z'],
+ ] as const) {
+ sim.quoteOverride = (body) => {
+ if (body['dry'] === true) return undefined
+ const good = goodQuote(body) as { quoteRequest: Json }
+ return { ...good, quoteRequest: { ...good.quoteRequest, [field]: value } }
+ }
+ expect(await refusal(), `tampered ${field}`).toMatch(/didn't match/)
+ }
+ })
+
+ it('refuses amounts in the quote that do not match, or a thin minimum', async () => {
+ for (const change of [
+ { amountIn: '999' },
+ { minAmountIn: '999' },
+ { minAmountOut: '0' },
+ { amountOut: '0', minAmountOut: '0' },
+ ]) {
+ sim.quoteOverride = (body) => {
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, ...change } }
+ }
+ expect(await refusal()).toMatch(/didn't match/)
+ }
+ })
+
+ it('refuses a deposit address that is not an implicit account, or that needs a memo', async () => {
+ for (const change of [
+ { depositAddress: 'evil.near' },
+ { depositAddress: undefined },
+ { depositAddress: 'A'.repeat(64) },
+ ]) {
+ sim.quoteOverride = (body) => {
+ if (body['dry'] === true) return undefined
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, ...change } }
+ }
+ expect(await refusal()).toMatch(/deposit address/)
+ }
+ sim.quoteOverride = (body) => {
+ if (body['dry'] === true) return undefined
+ const good = goodQuote(body) as { quote: Json }
+ return { ...good, quote: { ...good.quote, depositMemo: '123' } }
+ }
+ expect(await refusal()).toMatch(/memo/)
+ })
+
+ it('refuses a quote with a bad signature', async () => {
+ vi.mocked(verifyQuoteSignature).mockReturnValue(false)
+ expect(await refusal()).toMatch(/signature check/)
+ })
+
+ it('refuses an unreadable quote', async () => {
+ sim.quoteOverride = () => ({ quote: { amountIn: 'not digits' } })
+ expect(await refusal()).toMatch(/cannot read/)
+ })
+
+ it('refuses when the deposit address cannot be checked on NEAR', async () => {
+ const real = vi.mocked(fetch).getMockImplementation()
+ vi.mocked(fetch).mockImplementation(async (input, init) => {
+ if (String(input).includes('fastnear')) throw new Error('down')
+ return (real as typeof fetch)(input, init)
+ })
+ expect(await refusal()).toMatch(/could not be read/)
+ })
+
+ it('refuses a request that is not a swap, an unknown token, or too many decimals', async () => {
+ expect(await refusal(swap({ tokenOut: 'BTC' }))).toMatch(/swaps between/)
+ expect(await refusal(swap({ tokenOut: 'USDC' }))).toMatch(/same token/)
+ expect(await refusal(swap({ amount: '1.1234567' }))).toMatch(/decimal places/)
+ const stellar = 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA'
+ await expect(oneClickVenue.prepare(swap(), stellar)).rejects.toThrow(/not a NEAR account/)
+ expect(sim.quotes).toHaveLength(0)
+ })
+})
+
+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)
+ 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/)
+ })
+
+ it('reads the price of the exact asset, not another with the same symbol', async () => {
+ const usdt = nearModule.tokens.find((t) => t.symbol === 'USDT')
+ expect(usdt).toBeDefined()
+ await expect(nearModule.priceUsd(usdt as NonNullable)).resolves.toBe(1)
+ })
+
+ it('has no price for a token 1Click lists with other decimals', async () => {
+ const usdc = nearModule.tokens.find((t) => t.symbol === 'USDC')
+ await expect(
+ nearModule.priceUsd({ ...(usdc as NonNullable), decimals: 18 })
+ ).resolves.toBeUndefined()
+ })
+
+ it('has no price when the token list fails, so the trade is refused', 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/
+ )
+ })
+})
+
+describe('submitting a 1Click swap', () => {
+ const executed =
+ (status: Json, over: Json = {}): (() => unknown) =>
+ () => ({
+ status,
+ transaction: {
+ signer_id: ME,
+ receiver_id: USDC_NEAR,
+ actions: [
+ {
+ FunctionCall: {
+ method_name: 'ft_transfer',
+ args: Buffer.from(JSON.stringify({ receiver_id: DEPOSIT, amount: '1' })).toString(
+ 'base64'
+ ),
+ deposit: '1',
+ },
+ },
+ ],
+ ...over,
+ },
+ })
+
+ const input = (over: Partial[0]> = {}) => ({
+ account: ME,
+ venueId: '1click',
+ signed: { family: 'near' as const, hashes: [HASH] },
+ context: { depositAddress: DEPOSIT },
+ ...over,
+ })
+
+ /** Runs a submit with its waits skipped. */
+ async function run(over: Parameters[0] = {}) {
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] })
+ const pending = submitOneClick(input(over))
+ await vi.runAllTimersAsync()
+ return pending
+ }
+
+ it('reports the deposit with the sender, and returns the explorer link on success', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ await expect(run()).resolves.toEqual({
+ ok: true,
+ hash: HASH,
+ explorerUrl: `https://nearblocks.io/txns/${HASH}`,
+ })
+ expect(sim.submits).toEqual([{ txHash: HASH, depositAddress: DEPOSIT, nearSenderAccount: ME }])
+ })
+
+ it('says plainly when the deposit transaction failed on NEAR', async () => {
+ sim.tx = executed({ Failure: { ActionError: {} } })
+ const result = await run()
+ expect(result).toMatchObject({ ok: false })
+ expect(!result.ok && result.error).toMatch(/failed on NEAR.*did not leave/)
+ expect(sim.submits).toHaveLength(0)
+ })
+
+ it('does not report a hash that is not this account paying this deposit address', async () => {
+ sim.tx = executed({ SuccessValue: '' }, { signer_id: 'someone.near' })
+ expect(await run()).toMatchObject({ ok: false })
+ sim.tx = executed({ SuccessValue: '' }, { actions: [] })
+ expect(await run()).toMatchObject({ ok: false })
+ expect(sim.submits).toHaveLength(0)
+ })
+
+ it('reads a transaction relayed through a delegate action as the real sender', async () => {
+ sim.tx = () => ({
+ status: { SuccessValue: '' },
+ transaction: {
+ signer_id: 'relayer.near',
+ receiver_id: ME,
+ actions: [
+ {
+ Delegate: {
+ delegate_action: {
+ sender_id: ME,
+ receiver_id: USDC_NEAR,
+ actions: [
+ {
+ FunctionCall: {
+ method_name: 'ft_transfer',
+ args: Buffer.from(
+ JSON.stringify({ receiver_id: DEPOSIT, amount: '1' })
+ ).toString('base64'),
+ deposit: '1',
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ })
+ await expect(run()).resolves.toMatchObject({ ok: true })
+ })
+
+ it('says what happened on REFUNDED, FAILED and INCOMPLETE_DEPOSIT', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ for (const [status, words] of [
+ ['REFUNDED', /refunded your funds to alice\.near/],
+ ['FAILED', /failed after your deposit.*alice\.near/],
+ ['INCOMPLETE_DEPOSIT', /less than the quote.*alice\.near/],
+ ] as const) {
+ sim.statuses = [status]
+ const result = await run()
+ expect(result.ok).toBe(false)
+ expect(!result.ok && result.error).toMatch(words)
+ }
+ })
+
+ it('waits through processing, then returns the outcome', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ sim.statuses = ['PENDING_DEPOSIT', 'KNOWN_DEPOSIT_TX', 'PROCESSING', 'SUCCESS']
+ await expect(run()).resolves.toMatchObject({ ok: true })
+ })
+
+ it('says it is still processing when the wait ends, inside 30 seconds', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ sim.statuses = ['PROCESSING']
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] })
+ const startedAt = Date.now()
+ const pending = submitOneClick(input())
+ await vi.runAllTimersAsync()
+ const result = await pending
+ expect(Date.now() - startedAt).toBeLessThan(30_000)
+ expect(result.ok).toBe(false)
+ expect(!result.ok && result.error).toMatch(/still processing.*Don't send it again/)
+ expect(!result.ok && result.error).toContain(DEPOSIT)
+ })
+
+ it('does not claim a deposit landed when nothing has seen it', async () => {
+ sim.tx = undefined
+ sim.statuses = ['PENDING_DEPOSIT']
+ const result = await run()
+ expect(!result.ok && result.error).toMatch(/neither NEAR nor NEAR Intents has seen it/)
+ expect(!result.ok && result.error).not.toMatch(/still processing/)
+ })
+
+ it('says still processing when NEAR confirms it but 1Click has not picked it up', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ sim.statuses = ['PENDING_DEPOSIT']
+ const result = await run()
+ expect(!result.ok && result.error).toMatch(/still processing/)
+ })
+
+ it('says still processing when only 1Click has seen it', async () => {
+ sim.tx = undefined
+ sim.statuses = ['KNOWN_DEPOSIT_TX']
+ const result = await run()
+ expect(!result.ok && result.error).toMatch(/still processing/)
+ })
+
+ it('keeps following the swap even when the RPC never sees the transaction', async () => {
+ sim.tx = undefined
+ sim.statuses = ['SUCCESS']
+ await expect(run()).resolves.toMatchObject({ ok: true })
+ expect(sim.submits).toHaveLength(0)
+ })
+
+ it('refuses a status for somebody else, a bad address and a bad hash', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ sim.statusRecipient = 'someone.near'
+ expect(await run()).toMatchObject({ ok: false, error: expect.stringMatching(/different swap/) })
+
+ expect(await run({ context: { depositAddress: 'evil.near' } })).toMatchObject({
+ ok: false,
+ error: expect.stringMatching(/deposit address was lost/),
+ })
+ expect(await run({ context: undefined })).toMatchObject({ ok: false })
+ expect(await run({ signed: { family: 'near', hashes: ['../../etc'] } })).toMatchObject({
+ ok: false,
+ })
+ expect(await run({ signed: { family: 'solana', signedTransaction: 'AA==' } })).toMatchObject({
+ ok: false,
+ })
+ })
+
+ it('never sends anything to the chain: only reads and 1Click notices', async () => {
+ sim.tx = executed({ SuccessValue: '' })
+ await run()
+ const methods = vi
+ .mocked(fetch)
+ .mock.calls.map(([, init]) => init?.body as string | undefined)
+ .filter((body) => body?.includes('jsonrpc'))
+ .map((body) => (JSON.parse(body as string) as { method: string }).method)
+ expect(methods.every((m) => m === 'tx' || m === 'query')).toBe(true)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-venues.test.ts b/apps/dapp/lib/__tests__/near-venues.test.ts
new file mode 100644
index 00000000..e5c44d5c
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-venues.test.ts
@@ -0,0 +1,140 @@
+import type { VenueCategory } from '@intent/types'
+import { describe, expect, it } from 'vitest'
+
+import { chainHelp } from '../chains/explain'
+import { nearCapabilities } from '../chains/near/capabilities'
+import { nearModule } from '../chains/near/module'
+import { nearVenues } from '../chains/near/venues'
+import { ChainActionRefused } from '../chains/types'
+import { venues } from '../venues'
+
+const CATEGORIES: readonly VenueCategory[] = [
+ 'swap',
+ 'aggregator',
+ 'orderbook',
+ 'lending',
+ 'rwa',
+ 'savings',
+ 'staking',
+ 'payments',
+ 'pool',
+ 'offramp',
+ 'perps',
+ 'names',
+ 'infrastructure',
+]
+
+describe('the NEAR venue list', () => {
+ it('has every row of the report, with the fields filled in', () => {
+ expect(nearVenues).toHaveLength(14)
+ for (const venue of nearVenues) {
+ expect(venue.family).toBe('near')
+ expect(venue.chains).toEqual(['NEAR'])
+ expect(CATEGORIES).toContain(venue.category)
+ expect(venue.url).toMatch(/^https:\/\//)
+ expect(venue.bestFor.length).toBeGreaterThan(10)
+ }
+ })
+
+ it('has ids that are unique across the whole app', () => {
+ const ids = venues.map((v) => v.id)
+ expect(new Set(ids).size).toBe(ids.length)
+ })
+
+ it('claims execution exactly for the venues the module wires', () => {
+ const claimed = nearVenues.filter((v) => v.integration === 'executes').map((v) => v.id)
+ expect(claimed.sort()).toEqual(nearModule.venues.map((v) => v.id).sort())
+ for (const venue of nearVenues) {
+ if (venue.integration === 'executes') expect(venue.capability).toBeTruthy()
+ else expect(venue.capability).toBeUndefined()
+ }
+ })
+
+ it('keeps Rhea Lend and the Linq route listed, and says why on the first', () => {
+ const lend = nearVenues.find((v) => v.id === 'rhea-lend')
+ expect(lend?.integration).toBeUndefined()
+ expect(lend?.bestFor).toMatch(/18\.4M/)
+ expect(nearVenues.find((v) => v.id === 'near-linq-cashout')?.integration).toBeUndefined()
+ })
+
+ it('never implies a guaranteed return', () => {
+ for (const venue of nearVenues) {
+ expect(`${venue.bestFor} ${venue.capability ?? ''}`).not.toMatch(
+ /guarantee|risk-free|safe yield/i
+ )
+ }
+ })
+})
+
+describe('NEAR capabilities', () => {
+ it('names one line per wired kind and venue, in words the venue picker understands', () => {
+ for (const capability of nearCapabilities) {
+ const wanted = capability.venue.toLowerCase()
+ const venue = nearModule.venues.find(
+ (v) =>
+ v.kinds.includes(capability.kind) &&
+ (v.id.toLowerCase() === wanted || v.name.toLowerCase().includes(wanted))
+ )
+ expect(venue, capability.venue).toBeDefined()
+ }
+ const kinds = new Set(nearModule.venues.flatMap((v) => [...v.kinds]))
+ for (const kind of kinds) {
+ expect(nearCapabilities.some((c) => c.kind === kind)).toBe(true)
+ }
+ })
+
+ it('gives help text that can be copied', () => {
+ const help = chainHelp('NEAR', nearCapabilities)
+ expect(help).toContain('"swap 10 USDC to NEAR"')
+ expect(help).toContain('"stake 1 NEAR with Meta Pool"')
+ expect(help).toContain('"stake 1 NEAR with rNEAR"')
+ expect(help).toContain('"supply 25 USDC on 1Click Earn"')
+ })
+})
+
+describe('1Click Earn', () => {
+ const ME = 'alice.near'
+ const DEPOSIT = 'dc2c3034e3ebefa1af9a2c419f896d7b761a056244099eefcd09aeb7efc1b86d'
+ const transfer = {
+ type: 'FunctionCall' as const,
+ params: {
+ methodName: 'ft_transfer',
+ args: { receiver_id: DEPOSIT, amount: '1000000' },
+ gas: '30000000000000',
+ deposit: '1',
+ },
+ }
+ const earn = nearModule.venues.find((v) => v.id === '1click-earn')
+
+ it('is the default for supply and does nothing else', () => {
+ expect(nearModule.defaultVenue.supply).toBe('1click-earn')
+ expect(earn?.kinds).toEqual(['supply'])
+ })
+
+ it('accepts a stablecoin deposit and refuses the wrap path a swap may use', () => {
+ const usdc = '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1'
+ const payload = (receiverId: string, actions: (typeof transfer)[]) => ({
+ family: 'near' as const,
+ transactions: [{ receiverId, actions }],
+ })
+ expect(() => earn?.assertSafe(payload(usdc, [transfer]), ME)).not.toThrow()
+ expect(() => earn?.assertSafe(payload('usdt.tether-token.near', [transfer]), ME)).not.toThrow()
+ expect(() => earn?.assertSafe(payload('evil.near', [transfer]), ME)).toThrow(ChainActionRefused)
+ const wrap = {
+ ...transfer,
+ params: { ...transfer.params, methodName: 'near_deposit', args: {}, deposit: '1000000' },
+ }
+ expect(() =>
+ earn?.assertSafe(payload('wrap.near', [wrap as typeof transfer, transfer]), ME)
+ ).toThrow(ChainActionRefused)
+ })
+
+ it('refuses anything but a stablecoin, before asking for a quote', async () => {
+ const supply = (tokenIn: string) => ({ kind: 'supply' as const, amount: '50', tokenIn })
+ await expect(earn?.prepare(supply('NEAR'), ME)).rejects.toThrow(/takes USDC or USDT/)
+ await expect(earn?.prepare(supply('DOGE'), ME)).rejects.toThrow(/takes USDC or USDT/)
+ await expect(
+ earn?.prepare({ kind: 'swap', amount: '50', tokenIn: 'USDC', tokenOut: 'NEAR' }, ME)
+ ).rejects.toThrow(/takes USDC or USDT/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/network-fetch.test.ts b/apps/dapp/lib/__tests__/network-fetch.test.ts
index 60907a95..14fb6512 100644
--- a/apps/dapp/lib/__tests__/network-fetch.test.ts
+++ b/apps/dapp/lib/__tests__/network-fetch.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
-import { networkFetch, setAttributedRace, setConnectedWallet } from '../api/network-fetch'
+import {
+ networkFetch,
+ setAttributedRace,
+ setConnectedWallet,
+ walletFetch,
+} from '../api/network-fetch'
const ORIGIN = 'https://app.test'
@@ -84,4 +89,26 @@ describe('networkFetch wallet header', () => {
expect(calls.map((c) => c.headers.get('x-intent-wallet'))).toEqual(['GWALLET', null, null])
})
+
+ it('walletFetch names the wallet without the wrapper installed, and keeps other headers', async () => {
+ const seen: Headers[] = []
+ const original = globalThis.fetch
+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ seen.push(new Headers(init?.headers))
+ return new Response('{}')
+ }) as typeof fetch
+
+ try {
+ setConnectedWallet('GWALLET')
+ await walletFetch('/api/cashout/verify', { headers: { 'Content-Type': 'application/json' } })
+ setConnectedWallet(undefined)
+ await walletFetch('/api/cashout/options')
+ } finally {
+ globalThis.fetch = original
+ }
+
+ expect(seen[0]?.get('x-intent-wallet')).toBe('GWALLET')
+ expect(seen[0]?.get('content-type')).toBe('application/json')
+ expect(seen[1]?.get('x-intent-wallet')).toBeNull()
+ })
})
diff --git a/apps/dapp/lib/__tests__/network-route.test.ts b/apps/dapp/lib/__tests__/network-route.test.ts
index d4cc9414..8b438aa4 100644
--- a/apps/dapp/lib/__tests__/network-route.test.ts
+++ b/apps/dapp/lib/__tests__/network-route.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
import { gateEnabled } from '../server/access-gate'
import {
bareRedirect,
+ homeFor,
+ rememberedChain,
isGateFree,
legacyStellarRedirect,
segmentNetwork,
@@ -35,15 +37,12 @@ describe('legacyStellarRedirect', () => {
})
describe('bareRedirect', () => {
- it('sends the bare root to the home chain, keeping the query', () => {
- expect(bareRedirect('/', '', 'stellar-mainnet')).toBe('/stellar-mainnet/intents')
- expect(bareRedirect('/', '?rule=r3', 'stellar')).toBe('/stellar/intents?rule=r3')
- })
-
it('sends the old unprefixed screens to the same screen on the home chain', () => {
- expect(bareRedirect('/apps', '?x=1', 'stellar-mainnet')).toBe('/stellar-mainnet/apps?x=1')
- expect(bareRedirect('/history', '', 'stellar')).toBe('/stellar/history')
- expect(bareRedirect('/competitions/abc', '', 'stellar-mainnet')).toBe(
+ expect(bareRedirect('/apps', '?x=1', 'stellar-mainnet', undefined)).toBe(
+ '/stellar-mainnet/apps?x=1'
+ )
+ expect(bareRedirect('/history', '', 'stellar', undefined)).toBe('/stellar/history')
+ expect(bareRedirect('/competitions/abc', '', 'stellar-mainnet', undefined)).toBe(
'/stellar-mainnet/competitions/abc'
)
})
@@ -59,7 +58,7 @@ describe('bareRedirect', () => {
'/intentsx/1',
'/api/health',
]) {
- expect(bareRedirect(path, '', 'stellar-mainnet')).toBeUndefined()
+ expect(bareRedirect(path, '', 'stellar-mainnet', undefined)).toBeUndefined()
}
})
})
@@ -95,3 +94,31 @@ describe('gateEnabled for a network', () => {
expect(gateEnabled({ ACCESS_GATE: 'maybe' }, 'mainnet')).toBe(false)
})
})
+
+describe('homeFor', () => {
+ it('opens on the chain last connected to', () => {
+ expect(homeFor('near', ['mainnet'])).toBe('near')
+ })
+
+ it('falls back to the home chain with no cookie, or one naming no chain', () => {
+ expect(homeFor(undefined, ['mainnet'])).toBe('stellar')
+ expect(homeFor('../evil', ['mainnet'])).toBe('stellar')
+ expect(homeFor('stellar-testnet', ['mainnet'])).toBe('stellar')
+ })
+})
+
+describe('the bare root', () => {
+ it('is the connect page, with no chain in the address', () => {
+ expect(bareRedirect('/', '', 'stellar', undefined)).toBeUndefined()
+ })
+
+ it('opens on the chain remembered from an earlier visit', () => {
+ expect(bareRedirect('/', '?a=1', 'stellar', 'near')).toBe('/near/intents?a=1')
+ })
+
+ it('ignores a remembered value that names no chain', () => {
+ expect(rememberedChain('../evil', ['mainnet'])).toBeUndefined()
+ expect(rememberedChain('near', ['mainnet'])).toBe('near')
+ expect(rememberedChain(undefined, ['mainnet'])).toBeUndefined()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/network-select.test.ts b/apps/dapp/lib/__tests__/network-select.test.ts
index 4141f67b..67c2173a 100644
--- a/apps/dapp/lib/__tests__/network-select.test.ts
+++ b/apps/dapp/lib/__tests__/network-select.test.ts
@@ -108,20 +108,37 @@ describe('chain segments', () => {
it('rejects a network the deployment does not serve, and anything unknown', async () => {
const { parseChainSegment } = await load()
expect(parseChainSegment('stellar-mainnet', ['testnet'])).toBeUndefined()
- expect(parseChainSegment('solana', ['testnet', 'mainnet'])).toBeUndefined()
+ expect(parseChainSegment('ethereum', ['testnet', 'mainnet'])).toBeUndefined()
expect(parseChainSegment('stellar-pubnet', ['testnet', 'mainnet'])).toBeUndefined()
})
it('keeps /stellar as the address when one network is served, and names the network when several are', async () => {
const single = await load()
expect(single.chainSegment('stellar', 'testnet')).toBe('stellar')
- expect(single.chainSegments()).toEqual(['arc', 'stellar'])
+ expect(single.chainSegments()).toEqual([
+ 'arc',
+ 'solana',
+ 'avalanche',
+ 'sui',
+ 'monad',
+ 'near',
+ 'stellar',
+ ])
const multi = await load({ NEXT_PUBLIC_STELLAR_NETWORKS: 'testnet,mainnet' })
expect(multi.chainSegment('stellar', 'mainnet')).toBe('stellar-mainnet')
expect(multi.chainSegment('stellar')).toBe('stellar-testnet')
expect(multi.chainSegment('arc')).toBe('arc')
- expect(multi.chainSegments()).toEqual(['arc', 'stellar-testnet', 'stellar-mainnet'])
+ expect(multi.chainSegments()).toEqual([
+ 'arc',
+ 'solana',
+ 'avalanche',
+ 'sui',
+ 'monad',
+ 'near',
+ 'stellar-testnet',
+ 'stellar-mainnet',
+ ])
})
})
diff --git a/apps/dapp/lib/__tests__/other-stellar-network.test.ts b/apps/dapp/lib/__tests__/other-stellar-network.test.ts
index 9466c173..f8e80910 100644
--- a/apps/dapp/lib/__tests__/other-stellar-network.test.ts
+++ b/apps/dapp/lib/__tests__/other-stellar-network.test.ts
@@ -15,21 +15,27 @@ describe('otherStellarNetwork', () => {
it('keeps the user on the same screen of the other deployment', () => {
expect(
- otherStellarNetwork('mainnet', 'https://testnet.example.com', '/stellar/apps').href
- ).toBe('https://testnet.example.com/stellar/apps')
+ otherStellarNetwork('testnet', 'https://mainnet.example.com', '/stellar/apps').href
+ ).toBe('https://mainnet.example.com/stellar/apps')
expect(
- otherStellarNetwork('mainnet', 'https://testnet.example.com/', '/stellar/apps').href
- ).toBe('https://testnet.example.com/stellar/apps')
+ otherStellarNetwork('testnet', 'https://mainnet.example.com/', '/stellar/apps').href
+ ).toBe('https://mainnet.example.com/stellar/apps')
+ })
+
+ it('never links a mainnet deployment to testnet, even when an address is configured', () => {
+ expect(
+ otherStellarNetwork('mainnet', 'https://testnet.example.com', '/stellar/apps').href
+ ).toBeUndefined()
})
it('refuses a configured address that is not http or https', () => {
for (const bad of [
'javascript:alert(1)',
'data:text/html,x',
- 'testnet.example.com',
+ 'mainnet.example.com',
'//evil.test',
]) {
- expect(otherStellarNetwork('mainnet', bad, '/stellar/apps').href, bad).toBeUndefined()
+ expect(otherStellarNetwork('testnet', bad, '/stellar/apps').href, bad).toBeUndefined()
}
})
})
diff --git a/apps/dapp/lib/__tests__/requester.test.ts b/apps/dapp/lib/__tests__/requester.test.ts
index e2ffb8fc..cb5f73d3 100644
--- a/apps/dapp/lib/__tests__/requester.test.ts
+++ b/apps/dapp/lib/__tests__/requester.test.ts
@@ -30,7 +30,13 @@ describe('requesterOf', () => {
expect(requesterOf(req())).toBeUndefined()
})
- it('is nobody for a wallet header that is not a Stellar account', () => {
+ it('keys records by a wallet on any connectable chain', () => {
+ expect(requesterOf(req({ [WALLET_HEADER]: 'alice.near' }))).toEqual({
+ email: 'wallet:alice.near',
+ })
+ })
+
+ it('is nobody for a wallet header that is not an address', () => {
expect(requesterOf(req({ [WALLET_HEADER]: 'not-an-address' }))).toBeUndefined()
expect(requesterOf(req({ [WALLET_HEADER]: `${wallet},${wallet}` }))).toBeUndefined()
})
diff --git a/apps/dapp/lib/__tests__/solana-assert-safe.test.ts b/apps/dapp/lib/__tests__/solana-assert-safe.test.ts
new file mode 100644
index 00000000..479cd9c1
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-assert-safe.test.ts
@@ -0,0 +1,490 @@
+import {
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getTransactionDecoder,
+ type Address,
+} from '@solana/kit'
+import { describe, expect, it } from 'vitest'
+
+import {
+ assertDepositAmount,
+ assertWrapAtMost,
+ decodeSolanaTransaction,
+ ownWrappedSolAccount,
+ solanaAssertSafe,
+} from '../chains/solana/assert-safe'
+import {
+ CAPTURE_ACCOUNT,
+ JUPITER_DFLOW_ROUTE,
+ JUPITER_LEND_USDC_DEPOSIT,
+ JUPITER_SOL_TO_USDC,
+ JUPITER_USDC_TO_ONYC,
+ KAMINO_USDC_DEPOSIT,
+ LULO_USDC_DEPOSIT,
+} from '../chains/solana/__fixtures__/captured-transactions'
+import {
+ JUPITER_LEND_POLICY,
+ JUPITER_SWAP_POLICY,
+ KAMINO_POLICY,
+ LULO_POLICY,
+ JUPITER_LEND_PROGRAM,
+ KVAULT_PROGRAM,
+} from '../chains/solana/programs'
+import { ChainActionRefused } from '../chains/types'
+
+const ATTACKER = 'BPFLoaderUpgradeab1e11111111111111111111111' as Address
+const FOREIGN_PROGRAM = 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK' as Address
+const SYSTEM = '11111111111111111111111111111111' as Address
+const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address
+const COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111' as Address
+const ATA = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' as Address
+
+type Message = {
+ version: number | 'legacy'
+ header: {
+ numSignerAccounts: number
+ numReadonlySignerAccounts: number
+ numReadonlyNonSignerAccounts: number
+ }
+ staticAccounts: readonly Address[]
+ lifetimeToken: string
+ instructions: readonly {
+ programAddressIndex: number
+ accountIndices?: readonly number[]
+ data?: Uint8Array
+ }[]
+ addressTableLookups?: readonly unknown[]
+}
+
+function open(base64: string): Message {
+ const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64'))
+ return getCompiledTransactionMessageDecoder().decode(messageBytes) as unknown as Message
+}
+
+/** Re-serialises a changed message in front of a single empty signature slot, or several. */
+function close(message: Message, signatureCount = message.header.numSignerAccounts): string {
+ const bytes = getCompiledTransactionMessageEncoder().encode(
+ message as unknown as Parameters<
+ ReturnType['encode']
+ >[0]
+ )
+ return Buffer.concat([
+ Buffer.from([signatureCount]),
+ Buffer.alloc(64 * signatureCount),
+ Buffer.from(bytes),
+ ]).toString('base64')
+}
+
+/**
+ * Appends a read-only account to the static list. Lookup table indices sit after
+ * the static accounts, so they shift up by one to keep pointing at the same thing.
+ */
+function withStatic(message: Message, address: Address): { message: Message; index: number } {
+ const oldCount = message.staticAccounts.length
+ return {
+ index: oldCount,
+ message: {
+ ...message,
+ header: {
+ ...message.header,
+ numReadonlyNonSignerAccounts: message.header.numReadonlyNonSignerAccounts + 1,
+ },
+ staticAccounts: [...message.staticAccounts, address],
+ instructions: message.instructions.map((ix) => ({
+ ...ix,
+ programAddressIndex:
+ ix.programAddressIndex >= oldCount ? ix.programAddressIndex + 1 : ix.programAddressIndex,
+ accountIndices: (ix.accountIndices ?? []).map((i) => (i >= oldCount ? i + 1 : i)),
+ })),
+ },
+ }
+}
+
+/** Adds an instruction for `program`, with the accounts it names added to the static list. */
+function withInstruction(
+ message: Message,
+ program: Address,
+ accounts: readonly Address[],
+ data: number[]
+): string {
+ let next = message
+ const indexOf = (address: Address): number => {
+ const found = next.staticAccounts.indexOf(address)
+ if (found !== -1) return found
+ const added = withStatic(next, address)
+ next = added.message
+ return added.index
+ }
+ const programAddressIndex = indexOf(program)
+ const accountIndices = accounts.map(indexOf)
+ next = {
+ ...next,
+ instructions: [
+ ...next.instructions,
+ { programAddressIndex, accountIndices, data: new Uint8Array(data) },
+ ],
+ }
+ return close(next)
+}
+
+function u64(value: bigint): number[] {
+ const out: number[] = []
+ let rest = value
+ for (let i = 0; i < 8; i += 1) {
+ out.push(Number(rest & BigInt(255)))
+ rest >>= BigInt(8)
+ }
+ return out
+}
+
+const systemTransfer = (lamports: bigint): number[] => [2, 0, 0, 0, ...u64(lamports)]
+
+const swapSafe = solanaAssertSafe(JUPITER_SWAP_POLICY)
+const lendSafe = solanaAssertSafe(JUPITER_LEND_POLICY)
+const kaminoSafe = solanaAssertSafe(KAMINO_POLICY)
+const luloSafe = solanaAssertSafe(LULO_POLICY)
+const solana = (transaction: string) => ({ family: 'solana' as const, transaction })
+
+describe('real captured transactions pass', () => {
+ it.each([
+ ['jupiter swap with SOL wrapping', swapSafe, JUPITER_SOL_TO_USDC],
+ ['jupiter swap creating a token account', swapSafe, JUPITER_USDC_TO_ONYC],
+ ['jupiter lend deposit', lendSafe, JUPITER_LEND_USDC_DEPOSIT],
+ ['kamino deposit', kaminoSafe, KAMINO_USDC_DEPOSIT],
+ ['lulo deposit', luloSafe, LULO_USDC_DEPOSIT],
+ ])('%s', (_name, safe, transaction) => {
+ expect(() => safe(solana(transaction), CAPTURE_ACCOUNT)).not.toThrow()
+ })
+
+ it('still passes once re-encoded unchanged, which proves the tamper helper is honest', () => {
+ const again = close(open(JUPITER_SOL_TO_USDC))
+ expect(again).toBe(JUPITER_SOL_TO_USDC)
+ expect(() => swapSafe(solana(again), CAPTURE_ACCOUNT)).not.toThrow()
+ })
+})
+
+describe('fee payer and signers', () => {
+ it('refuses when the account checked is not the fee payer', () => {
+ expect(() => swapSafe(solana(JUPITER_SOL_TO_USDC), ATTACKER)).toThrow(ChainActionRefused)
+ expect(() => swapSafe(solana(JUPITER_SOL_TO_USDC), ATTACKER)).toThrow(/fee/i)
+ })
+
+ it('refuses a transaction whose fee payer was swapped', () => {
+ const message = open(JUPITER_SOL_TO_USDC)
+ const tampered = close({
+ ...message,
+ staticAccounts: [ATTACKER, ...message.staticAccounts.slice(1)],
+ })
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/fee/i)
+ })
+
+ it('refuses a second required signer', () => {
+ const message = open(JUPITER_LEND_USDC_DEPOSIT)
+ const tampered = close({ ...message, header: { ...message.header, numSignerAccounts: 2 } }, 2)
+ expect(() => lendSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/sign/i)
+ })
+
+ it('refuses a read-only fee payer', () => {
+ const message = open(JUPITER_LEND_USDC_DEPOSIT)
+ const tampered = close({
+ ...message,
+ header: { ...message.header, numReadonlySignerAccounts: 1 },
+ })
+ expect(() => lendSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses an account that is not a Solana address', () => {
+ expect(() =>
+ swapSafe(solana(JUPITER_SOL_TO_USDC), '0x0000000000000000000000000000000000000000')
+ ).toThrow(ChainActionRefused)
+ })
+})
+
+describe('programs', () => {
+ it('refuses a foreign program added to a real transaction', () => {
+ const tampered = withInstruction(open(JUPITER_SOL_TO_USDC), FOREIGN_PROGRAM, [], [1, 2, 3])
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/program/i)
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('does not take one venue’s program for another’s', () => {
+ expect(() => swapSafe(solana(KAMINO_USDC_DEPOSIT), CAPTURE_ACCOUNT)).toThrow(/program/i)
+ expect(() => kaminoSafe(solana(JUPITER_LEND_USDC_DEPOSIT), CAPTURE_ACCOUNT)).toThrow(/program/i)
+ expect(() => lendSafe(solana(JUPITER_USDC_TO_ONYC), CAPTURE_ACCOUNT)).toThrow(/program/i)
+ })
+
+ it('refuses a router the swap venue excludes', () => {
+ expect(() => swapSafe(solana(JUPITER_DFLOW_ROUTE), CAPTURE_ACCOUNT)).toThrow(/program/i)
+ })
+
+ it('refuses a program index that points into a lookup table', () => {
+ const message = open(JUPITER_SOL_TO_USDC)
+ const beyondStatic = message.staticAccounts.length + 1
+ const tampered = close({
+ ...message,
+ instructions: [
+ ...message.instructions,
+ { programAddressIndex: beyondStatic, accountIndices: [], data: new Uint8Array([1]) },
+ ],
+ })
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/lookup|program/i)
+ })
+
+ it('refuses a venue instruction outside the kinds this deposit needs', () => {
+ const message = open(KAMINO_USDC_DEPOSIT)
+ const instructions = message.instructions.map((ix) => {
+ if (message.staticAccounts[ix.programAddressIndex] !== KVAULT_PROGRAM) return ix
+ const data = Uint8Array.from(ix.data ?? [])
+ data[0] = (data[0] as number) ^ 0xff
+ return { ...ix, data }
+ })
+ expect(() => kaminoSafe(solana(close({ ...message, instructions })), CAPTURE_ACCOUNT)).toThrow(
+ /instruction/i
+ )
+ })
+})
+
+describe('system program', () => {
+ const ownWsol = (): Address => ownWrappedSolAccount(CAPTURE_ACCOUNT) as Address
+
+ it('refuses an added transfer to anyone else', () => {
+ const tampered = withInstruction(
+ open(JUPITER_SOL_TO_USDC),
+ SYSTEM,
+ [CAPTURE_ACCOUNT as Address, ATTACKER],
+ systemTransfer(BigInt(1_000_000_000))
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/transfer/i)
+ })
+
+ it('refuses a transfer on a venue that has no reason to wrap SOL', () => {
+ const tampered = withInstruction(
+ open(JUPITER_LEND_USDC_DEPOSIT),
+ SYSTEM,
+ [CAPTURE_ACCOUNT as Address, ownWsol()],
+ systemTransfer(BigInt(1))
+ )
+ expect(() => lendSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a transfer whose destination hides in a lookup table', () => {
+ const message = open(JUPITER_SOL_TO_USDC)
+ const systemIndex = message.staticAccounts.indexOf(SYSTEM)
+ const tampered = close({
+ ...message,
+ instructions: [
+ ...message.instructions,
+ {
+ programAddressIndex: systemIndex,
+ accountIndices: [0, message.staticAccounts.length + 2],
+ data: new Uint8Array(systemTransfer(BigInt(1))),
+ },
+ ],
+ })
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it.each([
+ [
+ 'create account',
+ [0, 0, 0, 0, ...u64(BigInt(1)), ...u64(BigInt(0)), ...new Array(32).fill(0)],
+ ],
+ ['assign', [1, 0, 0, 0, ...new Array(32).fill(0)]],
+ ['advance nonce', [4, 0, 0, 0]],
+ ['withdraw nonce', [5, 0, 0, 0, ...u64(BigInt(1))]],
+ ['transfer with seed', [11, 0, 0, 0, ...u64(BigInt(1))]],
+ ])('refuses %s', (_name, data) => {
+ const tampered = withInstruction(
+ open(JUPITER_SOL_TO_USDC),
+ SYSTEM,
+ [CAPTURE_ACCOUNT as Address, ATTACKER],
+ data
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('token program', () => {
+ const account = CAPTURE_ACCOUNT as Address
+ const wsol = (): Address => ownWrappedSolAccount(CAPTURE_ACCOUNT) as Address
+
+ it.each([
+ ['transfer', [3, ...u64(BigInt(1))]],
+ ['approve', [4, ...u64(BigInt(1))]],
+ ['set authority', [6, 2, 0]],
+ ['transfer checked', [12, ...u64(BigInt(1)), 6]],
+ ['burn', [8, ...u64(BigInt(1))]],
+ ])('refuses a top-level %s', (_name, data) => {
+ const tampered = withInstruction(
+ open(JUPITER_SOL_TO_USDC),
+ TOKEN,
+ [wsol(), ATTACKER, account],
+ data
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses closing the wrapped account into someone else', () => {
+ const tampered = withInstruction(
+ open(JUPITER_SOL_TO_USDC),
+ TOKEN,
+ [wsol(), ATTACKER, account],
+ [9]
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/close/i)
+ })
+
+ it('refuses closing some other token account', () => {
+ const tampered = withInstruction(
+ open(JUPITER_SOL_TO_USDC),
+ TOKEN,
+ [ATTACKER, account, account],
+ [9]
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/close/i)
+ })
+
+ it('refuses syncing some other account', () => {
+ const tampered = withInstruction(open(JUPITER_SOL_TO_USDC), TOKEN, [ATTACKER], [17])
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('associated token program', () => {
+ const account = CAPTURE_ACCOUNT as Address
+
+ it('refuses a token account created for someone else', () => {
+ const tampered = withInstruction(
+ open(JUPITER_USDC_TO_ONYC),
+ ATA,
+ [account, ATTACKER, ATTACKER, ATTACKER, SYSTEM, TOKEN],
+ [1]
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/token account/i)
+ })
+
+ it('refuses a token account paid for by someone else', () => {
+ const tampered = withInstruction(
+ open(JUPITER_USDC_TO_ONYC),
+ ATA,
+ [ATTACKER, ATTACKER, account, ATTACKER, SYSTEM, TOKEN],
+ [1]
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/token account/i)
+ })
+
+ it('refuses recovering a nested token account', () => {
+ const tampered = withInstruction(
+ open(JUPITER_USDC_TO_ONYC),
+ ATA,
+ [account, ATTACKER, account, ATTACKER, SYSTEM, TOKEN],
+ [2]
+ )
+ expect(() => swapSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('compute budget', () => {
+ const setPrice = (microLamports: bigint): number[] => [3, ...u64(microLamports)]
+
+ it('refuses a priority fee that would drain the fee payer', () => {
+ const tampered = withInstruction(
+ open(JUPITER_LEND_USDC_DEPOSIT),
+ COMPUTE_BUDGET,
+ [],
+ setPrice(BigInt(10_000_000_000))
+ )
+ expect(() => lendSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(/priority fee/i)
+ })
+
+ it('allows the priority fees the live APIs set', () => {
+ const tampered = withInstruction(
+ open(JUPITER_LEND_USDC_DEPOSIT),
+ COMPUTE_BUDGET,
+ [],
+ setPrice(BigInt(300_000))
+ )
+ expect(() => lendSafe(solana(tampered), CAPTURE_ACCOUNT)).not.toThrow()
+ })
+
+ it('refuses an unknown compute budget instruction', () => {
+ const tampered = withInstruction(open(JUPITER_LEND_USDC_DEPOSIT), COMPUTE_BUDGET, [], [9, 0])
+ expect(() => lendSafe(solana(tampered), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+})
+
+describe('malformed payloads', () => {
+ it('refuses garbage', () => {
+ expect(() => swapSafe(solana('not base64 at all !!'), CAPTURE_ACCOUNT)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => swapSafe(solana('AAAA'), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses trailing bytes smuggled after the message', () => {
+ const padded = Buffer.concat([
+ Buffer.from(JUPITER_LEND_USDC_DEPOSIT, 'base64'),
+ Buffer.from([1, 2, 3]),
+ ]).toString('base64')
+ expect(() => lendSafe(solana(padded), CAPTURE_ACCOUNT)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses another chain’s payload', () => {
+ expect(() =>
+ swapSafe({ family: 'sui', transaction: JUPITER_SOL_TO_USDC }, CAPTURE_ACCOUNT)
+ ).toThrow(ChainActionRefused)
+ })
+})
+
+describe('ownWrappedSolAccount', () => {
+ it('derives the account the live API used, synchronously', () => {
+ expect(ownWrappedSolAccount(CAPTURE_ACCOUNT)).toBe(
+ 'CDpxuy4Liae8HPUKA788gLi3QRgnH17PEEip2zem8hHw'
+ )
+ })
+})
+
+describe('decodeSolanaTransaction', () => {
+ it('reads the fee payer and leaves the signature slot empty on an unsigned transaction', () => {
+ const decoded = decodeSolanaTransaction(JUPITER_LEND_USDC_DEPOSIT)
+ expect(decoded.feePayer).toBe(CAPTURE_ACCOUNT)
+ expect(decoded.feePayerSignature).toBeNull()
+ })
+})
+
+describe('assertDepositAmount', () => {
+ it('accepts the amount the capture was built for', () => {
+ expect(() =>
+ assertDepositAmount(JUPITER_LEND_USDC_DEPOSIT, JUPITER_LEND_PROGRAM, '1000000')
+ ).not.toThrow()
+ expect(() => assertDepositAmount(KAMINO_USDC_DEPOSIT, KVAULT_PROGRAM, '1000000')).not.toThrow()
+ })
+
+ it('refuses a deposit of a different amount from the one asked for', () => {
+ expect(() => assertDepositAmount(KAMINO_USDC_DEPOSIT, KVAULT_PROGRAM, '999999')).toThrow(
+ /amount/i
+ )
+ })
+
+ it('refuses a transaction with no deposit in it', () => {
+ expect(() =>
+ assertDepositAmount(JUPITER_USDC_TO_ONYC, JUPITER_LEND_PROGRAM, '1000000')
+ ).toThrow(ChainActionRefused)
+ })
+})
+
+describe('assertWrapAtMost', () => {
+ it('allows wrapping what the swap spends', () => {
+ expect(() => assertWrapAtMost(JUPITER_SOL_TO_USDC, BigInt(10_000_000))).not.toThrow()
+ })
+
+ it('refuses wrapping more than the swap spends', () => {
+ expect(() => assertWrapAtMost(JUPITER_SOL_TO_USDC, BigInt(9_999_999))).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('counts nothing on a transaction that does not wrap', () => {
+ expect(() => assertWrapAtMost(JUPITER_USDC_TO_ONYC, BigInt(0))).not.toThrow()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/solana-submit.test.ts b/apps/dapp/lib/__tests__/solana-submit.test.ts
new file mode 100644
index 00000000..e337f63d
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-submit.test.ts
@@ -0,0 +1,303 @@
+import { getBase58Decoder } from '@solana/kit'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap'
+import { createSolanaSubmit } from '../chains/solana/submit'
+import {
+ CAPTURE_ACCOUNT,
+ JUPITER_LEND_USDC_DEPOSIT,
+ JUPITER_SOL_TO_USDC,
+} from '../chains/solana/__fixtures__/captured-transactions'
+import type { SubmitInput } from '../chains/types'
+
+/**
+ * No signature here is real, and nothing reaches a network: fetch is stubbed. The
+ * bytes stand in for what a wallet returns, so the signature slot is only non-zero.
+ */
+function fakeSigned(base64: string): { transaction: string; signature: string } {
+ const bytes = Buffer.from(base64, 'base64')
+ bytes.fill(7, 1, 65)
+ return {
+ transaction: bytes.toString('base64'),
+ signature: getBase58Decoder().decode(bytes.subarray(1, 65)),
+ }
+}
+
+const signed = fakeSigned(JUPITER_LEND_USDC_DEPOSIT)
+const input = (signedTransaction: string): SubmitInput => ({
+ account: CAPTURE_ACCOUNT,
+ venueId: 'kamino',
+ signed: { family: 'solana', signedTransaction },
+})
+
+type Rpc = (method: string, params: unknown[]) => unknown
+
+function stubRpc(rpc: Rpc): { calls: { url: string; method: string; params: unknown[] }[] } {
+ const calls: { url: string; method: string; params: unknown[] }[] = []
+ vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {
+ const { method, params } = JSON.parse(String(init.body)) as {
+ method: string
+ params: unknown[]
+ }
+ calls.push({ url, method, params })
+ return new Response(
+ JSON.stringify({ jsonrpc: '2.0', id: 1, ...(rpc(method, params) as object) })
+ )
+ })
+ return { calls }
+}
+
+const ok = (result: unknown) => ({ result })
+const status = (value: unknown) => ok({ context: { slot: 1 }, value: [value] })
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('solana submit', () => {
+ const submit = (env: Record = {}, maxPolls = 3) =>
+ createSolanaSubmit({ env, pollMs: 0, maxPolls })
+
+ it('sends, polls and returns the hash and a Solscan link', async () => {
+ let polls = 0
+ const { calls } = stubRpc((method) => {
+ if (method === 'sendTransaction') return ok(signed.signature)
+ polls += 1
+ return polls === 1 ? status(null) : status({ confirmationStatus: 'confirmed', err: null })
+ })
+ await expect(submit()(input(signed.transaction))).resolves.toEqual({
+ ok: true,
+ hash: signed.signature,
+ explorerUrl: `https://solscan.io/tx/${signed.signature}`,
+ })
+ expect(calls[0]?.url).toBe('https://solana-rpc.publicnode.com')
+ expect(calls[0]?.params[0]).toBe(signed.transaction)
+ expect(calls[0]?.params[1]).toMatchObject({ encoding: 'base64' })
+ expect(polls).toBe(2)
+ })
+
+ it('uses SOLANA_RPC_URL when set', async () => {
+ const { calls } = stubRpc((method) =>
+ method === 'sendTransaction'
+ ? ok(signed.signature)
+ : status({ confirmationStatus: 'finalized', err: null })
+ )
+ await submit({ SOLANA_RPC_URL: 'https://rpc.example.test' })(input(signed.transaction))
+ expect(calls[0]?.url).toBe('https://rpc.example.test')
+ })
+
+ it('refuses unsigned bytes without calling anyone', async () => {
+ const { calls } = stubRpc(() => ok(''))
+ const result = await submit()(input(JUPITER_LEND_USDC_DEPOSIT))
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/sign/i) })
+ expect(calls).toHaveLength(0)
+ })
+
+ it('refuses another chain’s payload', async () => {
+ const result = await submit()({
+ account: CAPTURE_ACCOUNT,
+ venueId: 'kamino',
+ signed: { family: 'near', hashes: ['x'] },
+ })
+ expect(result.ok).toBe(false)
+ })
+
+ it('explains an expired blockhash at send', async () => {
+ stubRpc(() => ({
+ error: {
+ code: -32002,
+ message: 'Transaction simulation failed: Blockhash not found',
+ data: { err: 'BlockhashNotFound', logs: [] },
+ },
+ }))
+ const result = await submit()(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/expired/i) })
+ expect(result).toMatchObject({ error: expect.stringMatching(/nothing moved/i) })
+ })
+
+ it('explains a failed simulation without raw logs', async () => {
+ stubRpc(() => ({
+ error: {
+ code: -32002,
+ message: 'Transaction simulation failed',
+ data: { err: { InstructionError: [1, { Custom: 6001 }] }, logs: ['Program log: secret'] },
+ },
+ }))
+ const result = await submit()(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/simulation|check/i) })
+ expect(JSON.stringify(result)).not.toMatch(/secret|6001/)
+ })
+
+ it('explains a balance that is too low', async () => {
+ stubRpc(() => ({
+ error: {
+ code: -32002,
+ message: 'Transaction simulation failed',
+ data: {
+ err: { InstructionError: [1, { Custom: 1 }] },
+ logs: ['Program log: Error: insufficient funds'],
+ },
+ },
+ }))
+ const result = await submit()(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/balance|enough/i) })
+ })
+
+ it('says when no SOL is left for the fee', async () => {
+ stubRpc(() => ({
+ error: {
+ code: -32002,
+ message: 'Transaction simulation failed',
+ data: { err: 'AccountNotFound', logs: [] },
+ },
+ }))
+ const result = await submit()(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/SOL/) })
+ })
+
+ it('reports a transaction that landed and failed', async () => {
+ stubRpc((method) =>
+ method === 'sendTransaction'
+ ? ok(signed.signature)
+ : status({ confirmationStatus: 'confirmed', err: { InstructionError: [0, 'x'] } })
+ )
+ const result = await submit()(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringContaining('solscan.io') })
+ })
+
+ it('stops polling at its bound and calls an expired blockhash final', async () => {
+ const { calls } = stubRpc((method) => {
+ if (method === 'sendTransaction') return ok(signed.signature)
+ if (method === 'isBlockhashValid') return ok({ context: { slot: 1 }, value: false })
+ return status(null)
+ })
+ const result = await submit({}, 3)(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/expired/i) })
+ expect(calls.filter((c) => c.method === 'getSignatureStatuses')).toHaveLength(3)
+ })
+
+ it('does not claim failure while the transaction could still land', async () => {
+ stubRpc((method) => {
+ if (method === 'sendTransaction') return ok(signed.signature)
+ if (method === 'isBlockhashValid') return ok({ context: { slot: 1 }, value: true })
+ return status(null)
+ })
+ const result = await submit({}, 2)(input(signed.transaction))
+ expect(result).toMatchObject({ ok: false, error: expect.stringContaining('solscan.io') })
+ expect(result).toMatchObject({ error: expect.stringMatching(/before trying again/i) })
+ })
+
+ it('treats a failed send over the network as unknown, for the route to report', async () => {
+ vi.stubGlobal('fetch', async () => {
+ throw new Error('socket hang up')
+ })
+ await expect(submit()(input(signed.transaction))).rejects.toThrow()
+ })
+
+ it('keeps polling through a failed status read', async () => {
+ let polls = 0
+ vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => {
+ const { method } = JSON.parse(String(init.body)) as { method: string }
+ if (method === 'sendTransaction') {
+ return new Response(JSON.stringify({ result: signed.signature }))
+ }
+ polls += 1
+ if (polls === 1) throw new Error('flaky')
+ return new Response(
+ JSON.stringify({ result: { value: [{ confirmationStatus: 'confirmed', err: null }] } })
+ )
+ })
+ await expect(submit()(input(signed.transaction))).resolves.toMatchObject({ ok: true })
+ })
+})
+
+describe('jupiter execute', () => {
+ const swapSigned = fakeSigned(JUPITER_SOL_TO_USDC)
+ const venue = createJupiterSwapVenue({ priceUsd: async () => 1, env: {}, retryDelayMs: 0 })
+ const executeInput: SubmitInput = {
+ account: CAPTURE_ACCOUNT,
+ venueId: 'jupiter',
+ signed: { family: 'solana', signedTransaction: swapSigned.transaction },
+ context: { requestId: 'request-1' },
+ }
+
+ function answering(body: unknown, statusCode = 200): { requests: unknown[]; urls: string[] } {
+ const requests: unknown[] = []
+ const urls: string[] = []
+ vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {
+ urls.push(url)
+ requests.push(JSON.parse(String(init.body)))
+ return new Response(JSON.stringify(body), { status: statusCode })
+ })
+ return { requests, urls }
+ }
+
+ it('passes the signed bytes and request id to execute, and returns the hash', async () => {
+ const { requests, urls } = answering({
+ status: 'Success',
+ signature: swapSigned.signature,
+ code: 0,
+ })
+ await expect(venue.submit?.(executeInput)).resolves.toEqual({
+ ok: true,
+ hash: swapSigned.signature,
+ explorerUrl: `https://solscan.io/tx/${swapSigned.signature}`,
+ })
+ expect(urls[0]).toBe('https://api.jup.ag/swap/v2/execute')
+ expect(requests[0]).toEqual({
+ signedTransaction: swapSigned.transaction,
+ requestId: 'request-1',
+ })
+ })
+
+ it('refuses without a request id', async () => {
+ answering({})
+ const { context: _context, ...rest } = executeInput
+ await expect(venue.submit?.(rest)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringMatching(/fresh quote/i),
+ })
+ })
+
+ it('refuses unsigned bytes without calling Jupiter', async () => {
+ const { urls } = answering({})
+ await expect(
+ venue.submit?.({
+ ...executeInput,
+ signed: { family: 'solana', signedTransaction: JUPITER_SOL_TO_USDC },
+ })
+ ).resolves.toMatchObject({ ok: false })
+ expect(urls).toHaveLength(0)
+ })
+
+ it.each([
+ [-1, /expired|fresh quote/i],
+ [-1005, /expired/i],
+ [-1004, /expired/i],
+ [-1000, /did not land/i],
+ [-1003, /sign/i],
+ [-9999, /did not go through/i],
+ ])('explains execute code %i', async (code, message) => {
+ answering({ status: 'Failed', code, error: 'upstream text' })
+ const result = await venue.submit?.(executeInput)
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(message) })
+ expect(JSON.stringify(result)).not.toContain('upstream text')
+ })
+
+ it('reports a rejected request as nothing sent', async () => {
+ answering({ error: 'bad' }, 400)
+ await expect(venue.submit?.(executeInput)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringMatching(/fresh quote/i),
+ })
+ })
+
+ it('leaves an unknown outcome to the route, which tells the user to check their wallet', async () => {
+ answering({}, 500)
+ await expect(venue.submit?.(executeInput)).rejects.toThrow()
+ vi.stubGlobal('fetch', async () => {
+ throw new DOMException('timed out', 'TimeoutError')
+ })
+ await expect(venue.submit?.(executeInput)).rejects.toThrow()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/solana-venues.live.test.ts b/apps/dapp/lib/__tests__/solana-venues.live.test.ts
new file mode 100644
index 00000000..d7f6cd1e
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-venues.live.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest'
+
+import { prepareChainAction } from '../chains/prepare'
+import { createJupiterLendVenue } from '../chains/solana/jupiter-lend'
+import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap'
+import { createKaminoVenue } from '../chains/solana/kamino'
+import { createLuloVenue } from '../chains/solana/lulo'
+import { createPriceSource } from '../chains/solana/price'
+import { SOLANA_TOKENS } from '../chains/solana/tokens'
+import { decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import type { ChainModule, ChainVenue, PreparedAction } from '../chains/types'
+
+/**
+ * Builds an unsigned transaction per venue against the live APIs and runs each
+ * venue's safety check on it. Read-only: nothing here signs or sends, and Lulo runs
+ * only when `LULO_API_KEY` is set. This is the check that catches an API changing a program id
+ * or a response field, which a captured fixture never would.
+ *
+ * The account is a public exchange wallet with USDC and SOL, so a venue that checks
+ * balances still builds. A keyless caller gets one Jupiter request per two seconds,
+ * so these run one after another with a pause.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+const SLOW_MS = 60_000
+
+const priceUsd = createPriceSource()
+
+function moduleWith(venue: ChainVenue): ChainModule {
+ return {
+ chain: 'solana',
+ tokens: SOLANA_TOKENS,
+ venues: [venue],
+ defaultVenue: { swap: venue.id, supply: venue.id },
+ priceUsd,
+ submit: async () => {
+ throw new Error('This test must never send anything.')
+ },
+ }
+}
+
+const pause = (): Promise => new Promise((resolve) => setTimeout(resolve, 2_500))
+
+function expectUnsignedFromFunded(prepared: PreparedAction): void {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected a Solana payload.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ expect(decoded.feePayer).toBe(FUNDED)
+ expect(decoded.feePayerSignature).toBeNull()
+ expect(prepared.notes.length).toBeGreaterThan(0)
+}
+
+describe.skipIf(SKIP || !process.env['LULO_API_KEY'])('lulo against its live API', () => {
+ it(
+ 'lulo builds a USDC deposit with the configured key',
+ async () => {
+ const prepared = await prepareChainAction(
+ moduleWith(createLuloVenue({ apiKey: process.env['LULO_API_KEY'] as string })),
+ { kind: 'supply', amount: '1', tokenIn: 'USDC' },
+ FUNDED
+ )
+ expectUnsignedFromFunded(prepared)
+ },
+ SLOW_MS
+ )
+})
+
+describe.skipIf(SKIP)('solana venues against live APIs', () => {
+ const swap = createJupiterSwapVenue({ priceUsd })
+
+ it.each([
+ ['USDC', 'SOL', '1'],
+ ['SOL', 'USDC', '0.01'],
+ ['USDC', 'USDT', '1'],
+ ['USDC', 'JitoSOL', '1'],
+ ['USDC', 'ONyc', '10'],
+ ])(
+ 'jupiter builds %s to %s',
+ async (tokenIn, tokenOut, amount) => {
+ await pause()
+ const prepared = await prepareChainAction(
+ moduleWith(swap),
+ { kind: 'swap', amount, tokenIn, tokenOut },
+ FUNDED
+ )
+ expectUnsignedFromFunded(prepared)
+ expect(prepared.context?.['requestId']).toBeTruthy()
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'jupiter lend builds a USDC deposit and shows a plausible rate',
+ async () => {
+ await pause()
+ const prepared = await prepareChainAction(
+ moduleWith(createJupiterLendVenue()),
+ { kind: 'supply', amount: '1', tokenIn: 'USDC' },
+ FUNDED
+ )
+ expectUnsignedFromFunded(prepared)
+ expect(prepared.apy?.basePercent).toBeGreaterThan(0)
+ expect(prepared.apy?.basePercent).toBeLessThan(50)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'kamino builds a USDC deposit into the vault it names',
+ async () => {
+ await pause()
+ const prepared = await prepareChainAction(
+ moduleWith(createKaminoVenue()),
+ { kind: 'supply', amount: '1', tokenIn: 'USDC' },
+ FUNDED
+ )
+ expectUnsignedFromFunded(prepared)
+ expect(prepared.summary).toMatch(/vault/)
+ expect(prepared.apy?.basePercent).toBeGreaterThan(0)
+ expect(prepared.apy?.basePercent).toBeLessThan(50)
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts
new file mode 100644
index 00000000..e70d7ade
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-venues.test.ts
@@ -0,0 +1,785 @@
+import {
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getTransactionDecoder,
+} from '@solana/kit'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { parseChainAction } from '../chains/parse-action'
+import { prepareChainAction } from '../chains/prepare'
+import { solanaCapabilities } from '../chains/solana/capabilities'
+import { decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import { createJupiterLendVenue } from '../chains/solana/jupiter-lend'
+import { createJupiterSwapVenue } from '../chains/solana/jupiter-swap'
+import { createKaminoVenue } from '../chains/solana/kamino'
+import { buildSolanaVenues, solanaModule } from '../chains/solana/module'
+import { createLuloVenue } from '../chains/solana/lulo'
+import { createPriceSource } from '../chains/solana/price'
+import { JUPITER_LEND_PROGRAM } from '../chains/solana/programs'
+import { SOLANA_TOKENS } from '../chains/solana/tokens'
+import { solanaVenues } from '../chains/solana/venues'
+import {
+ CAPTURE_ACCOUNT,
+ JUPITER_LEND_USDC_DEPOSIT,
+ JUPITER_SOL_TO_USDC,
+ JUPITER_USDC_TO_ONYC,
+ KAMINO_USDC_DEPOSIT,
+ LULO_USDC_DEPOSIT,
+} from '../chains/solana/__fixtures__/captured-transactions'
+import { ChainActionRefused } from '../chains/types'
+
+const SOL = 'So11111111111111111111111111111111111111112'
+const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const USDT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
+const ONYC = '5Y8NV33Vv7WbnLfq3zBcKSdYPrk7g2KoiQoe7M2tcxp5'
+
+/** The captured deposit with its recipient token account pointed at someone else's. */
+function lendDepositWithRecipientSwapped(): string {
+ const { messageBytes } = getTransactionDecoder().decode(
+ Buffer.from(JUPITER_LEND_USDC_DEPOSIT, 'base64')
+ )
+ const message = getCompiledTransactionMessageDecoder().decode(messageBytes)
+ if (message.version !== 'legacy') throw new Error('Expected the captured legacy transaction.')
+ const deposit = message.instructions[0]
+ const recipientIndex = deposit?.accountIndices?.[2] as number
+ const staticAccounts = [...message.staticAccounts]
+ staticAccounts[recipientIndex] =
+ 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK' as (typeof staticAccounts)[number]
+ const bytes = getCompiledTransactionMessageEncoder().encode({ ...message, staticAccounts })
+ return Buffer.concat([Buffer.from([1]), Buffer.alloc(64), Buffer.from(bytes)]).toString('base64')
+}
+
+type Route = (url: string, init?: RequestInit) => { status?: number; body: unknown } | undefined
+
+function stubFetch(route: Route): { calls: { url: string; init?: RequestInit }[] } {
+ const calls: { url: string; init?: RequestInit }[] = []
+ vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => {
+ const url = String(input)
+ calls.push({ url, ...(init !== undefined ? { init } : {}) })
+ const answer = route(url, init)
+ if (answer === undefined) throw new Error(`unexpected fetch ${url}`)
+ return new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 })
+ })
+ return { calls }
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('tokens', () => {
+ it('holds exact mints confirmed from a primary source', () => {
+ expect(Object.fromEntries(SOLANA_TOKENS.map((t) => [t.symbol, [t.id, t.decimals]]))).toEqual({
+ SOL: [SOL, 9],
+ USDC: [USDC, 6],
+ USDT: [USDT, 6],
+ JitoSOL: ['J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn', 9],
+ ONyc: [ONYC, 9],
+ })
+ })
+
+ it('never reaches a look-alike symbol outside the list', async () => {
+ await expect(
+ prepareChainAction(
+ solanaModule,
+ { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'USDC.e' },
+ CAPTURE_ACCOUNT,
+ {}
+ )
+ ).rejects.toThrow(/verified token/)
+ await expect(
+ prepareChainAction(
+ solanaModule,
+ { kind: 'swap', amount: '1', tokenIn: USDC, tokenOut: 'SOL' },
+ CAPTURE_ACCOUNT,
+ {}
+ )
+ ).rejects.toThrow(/verified token/)
+ })
+})
+
+describe('price source', () => {
+ const prices = { [USDC]: { usdPrice: 0.9994 }, [SOL]: { usdPrice: 93.19 } }
+ const usdc = SOLANA_TOKENS.find((t) => t.symbol === 'USDC')!
+ const sol = SOLANA_TOKENS.find((t) => t.symbol === 'SOL')!
+ const usdt = SOLANA_TOKENS.find((t) => t.symbol === 'USDT')!
+
+ it('uses the live price rather than assuming a stablecoin is a dollar', async () => {
+ stubFetch(() => ({ body: prices }))
+ await expect(createPriceSource({})(usdc)).resolves.toBe(0.9994)
+ })
+
+ it('returns nothing for a token the source leaves out', async () => {
+ stubFetch(() => ({ body: prices }))
+ await expect(createPriceSource({})(usdt)).resolves.toBeUndefined()
+ })
+
+ it('returns nothing, never a guess, when the source fails', async () => {
+ stubFetch(() => ({ status: 500, body: {} }))
+ await expect(createPriceSource({ retryDelayMs: 0 })(usdc)).resolves.toBeUndefined()
+ vi.stubGlobal('fetch', async () => {
+ throw new Error('down')
+ })
+ await expect(createPriceSource({ retryDelayMs: 0 })(usdc)).resolves.toBeUndefined()
+ })
+
+ it('refuses a price that is not a positive number', async () => {
+ stubFetch(() => ({ body: { [USDC]: { usdPrice: 0 }, [SOL]: { usdPrice: 'nan' } } }))
+ await expect(createPriceSource({})(usdc)).resolves.toBeUndefined()
+ })
+
+ it('fetches once for all tokens, so a keyless rate limit is not spent per token', async () => {
+ const { calls } = stubFetch(() => ({ body: prices }))
+ const price = createPriceSource({})
+ await Promise.all([price(usdc), price(sol)])
+ await price(usdc)
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.url).toContain(ONYC)
+ })
+})
+
+describe('jupiter swap', () => {
+ const order = {
+ inputMint: SOL,
+ outputMint: USDC,
+ inAmount: '10000000',
+ outAmount: '931580',
+ otherAmountThreshold: '922264',
+ slippageBps: 100,
+ priceImpact: -0.0097,
+ feeBps: 2,
+ router: 'metis',
+ gasless: false,
+ signatureFeePayer: CAPTURE_ACCOUNT,
+ rentFeeLamports: 0,
+ requestId: 'request-1',
+ taker: CAPTURE_ACCOUNT,
+ transaction: JUPITER_SOL_TO_USDC,
+ }
+ const request = { kind: 'swap', amount: '0.01', tokenIn: 'SOL', tokenOut: 'USDC' } as const
+ const priceUsd = async (token: { symbol: string }): Promise =>
+ ({ SOL: 93.19, USDC: 0.9994, ONyc: 1.1555 })[token.symbol]
+
+ function venue(env: Record = {}, price = priceUsd) {
+ return createJupiterSwapVenue({ priceUsd: price, env, retryDelayMs: 0 })
+ }
+
+ function answering(overrides: Record) {
+ return stubFetch(() => ({ body: { ...order, ...overrides } }))
+ }
+
+ it('builds an action from a real captured route', async () => {
+ const { calls } = answering({})
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared).toMatchObject({
+ venueId: 'jupiter',
+ kind: 'swap',
+ pay: { amount: '0.01', symbol: 'SOL' },
+ receive: { amount: '0.93158', symbol: 'USDC', approximate: true },
+ signing: { family: 'solana', transaction: JUPITER_SOL_TO_USDC },
+ context: { requestId: 'request-1' },
+ })
+ expect(prepared.notes.length).toBeGreaterThanOrEqual(1)
+ expect(prepared.notes.length).toBeLessThanOrEqual(3)
+ expect(prepared.notes.join(' ')).toContain('0.922264')
+
+ const url = new URL(calls[0]?.url ?? '')
+ expect(url.origin + url.pathname).toBe('https://api.jup.ag/swap/v2/order')
+ expect(url.searchParams.get('taker')).toBe(CAPTURE_ACCOUNT)
+ expect(url.searchParams.get('slippageBps')).toBe('100')
+ expect(url.searchParams.get('excludeRouters')).toBe('jupiterz,dflow,okx')
+ expect(url.searchParams.get('referralAccount')).toBeNull()
+ expect(new Headers(calls[0]?.init?.headers).get('x-api-key')).toBeNull()
+ })
+
+ it('sends a key only when one is set', async () => {
+ const { calls } = answering({})
+ await venue({ JUPITER_API_KEY: 'k' }).prepare(request, CAPTURE_ACCOUNT)
+ expect(new Headers(calls[0]?.init?.headers).get('x-api-key')).toBe('k')
+ })
+
+ it('charges a referral fee only when an account is set, and says so', async () => {
+ const { calls } = answering({})
+ const prepared = await venue({
+ JUPITER_REFERRAL_ACCOUNT: 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK',
+ }).prepare(request, CAPTURE_ACCOUNT)
+ const url = new URL(calls[0]?.url ?? '')
+ expect(url.searchParams.get('referralAccount')).toBe(
+ 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'
+ )
+ expect(url.searchParams.get('referralFee')).toBe('50')
+ expect(prepared.notes.join(' ')).toMatch(/0\.5%/)
+ })
+
+ it('ignores a referral account that is not an address', async () => {
+ const { calls } = answering({})
+ await venue({ JUPITER_REFERRAL_ACCOUNT: 'not-an-address' }).prepare(request, CAPTURE_ACCOUNT)
+ expect(new URL(calls[0]?.url ?? '').searchParams.get('referralAccount')).toBeNull()
+ })
+
+ it.each([
+ ['price impact over 3%', { priceImpact: -3.01 }, /price impact/i],
+ ['no price impact reported', { priceImpact: undefined }, /price impact/i],
+ ['a different input mint', { inputMint: USDT }, /not what you asked/i],
+ ['a different output mint', { outputMint: USDT }, /not what you asked/i],
+ ['a different input amount', { inAmount: '1' }, /not what you asked/i],
+ [
+ 'a different taker',
+ { taker: 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK' },
+ /not what you/i,
+ ],
+ ['slippage over 1%', { slippageBps: 101 }, /slippage/i],
+ ['a router this venue excludes', { router: 'jupiterz' }, /route/i],
+ [
+ 'someone else paying fees',
+ { signatureFeePayer: 'gasTzr94Pmp4Gf8vknQnqxeYxdgwFjbgdJa4msYRpnB' },
+ /0\.01 SOL/,
+ ],
+ ['a gasless flag', { gasless: true }, /0\.01 SOL/],
+ ['no request id', { requestId: undefined }, /could not be/i],
+ ['no minimum output', { otherAmountThreshold: undefined }, /could not be/i],
+ ])('refuses %s', async (_name, overrides, message) => {
+ answering(overrides)
+ const attempt = venue().prepare(request, CAPTURE_ACCOUNT)
+ await expect(attempt).rejects.toThrow(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(message)
+ })
+
+ it('refuses a return more than 5% worse than the input is worth', async () => {
+ answering({ otherAmountThreshold: String(Math.floor(0.94 * 0.9319 * 1e6)) })
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/less than/i)
+ })
+
+ it('accepts a return under 5% worse', async () => {
+ answering({ otherAmountThreshold: String(Math.floor(0.96 * 0.9319 * 1e6)) })
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).resolves.toBeDefined()
+ })
+
+ it('refuses when it cannot value either side', async () => {
+ answering({})
+ const noOut = async (token: { symbol: string }) => (token.symbol === 'USDC' ? undefined : 93.19)
+ await expect(venue({}, noOut).prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/value/i)
+ const noIn = async (token: { symbol: string }) => (token.symbol === 'SOL' ? undefined : 1)
+ await expect(venue({}, noIn).prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/value/i)
+ })
+
+ it.each([
+ [1, /enough SOL for this swap/i],
+ [2, /network fees/i],
+ [3, /too small/i],
+ [99, /could not build/i],
+ ])('explains order error %i', async (errorCode, message) => {
+ answering({ transaction: '', errorCode, errorMessage: 'upstream text' })
+ const attempt = venue().prepare(request, CAPTURE_ACCOUNT)
+ await expect(attempt).rejects.toThrow(message)
+ await expect(attempt).rejects.not.toThrow(/upstream text/)
+ })
+
+ it('explains a low balance even when no fee payer comes back with it', async () => {
+ answering({ transaction: '', errorCode: 1, signatureFeePayer: null })
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/enough SOL for this/)
+ })
+
+ it('refuses an account that is not a Solana address before calling anyone', async () => {
+ const { calls } = answering({})
+ await expect(
+ venue().prepare(request, '0x0000000000000000000000000000000000000000')
+ ).rejects.toThrow(/Solana wallet/)
+ expect(calls).toHaveLength(0)
+ })
+
+ it('warns that ONyc is not a dollar token and has redemption windows', async () => {
+ answering({
+ inputMint: USDC,
+ outputMint: ONYC,
+ inAmount: '10000000',
+ outAmount: '8640521185',
+ otherAmountThreshold: '8554115973',
+ transaction: JUPITER_USDC_TO_ONYC,
+ })
+ const prepared = await venue().prepare(
+ { kind: 'swap', amount: '10', tokenIn: 'USDC', tokenOut: 'onyc' },
+ CAPTURE_ACCOUNT
+ )
+ const notes = prepared.notes.join(' ')
+ expect(notes).toMatch(/not a \$1 token/)
+ expect(notes).toMatch(/redemption/i)
+ })
+
+ it('retries once when rate limited', async () => {
+ let first = true
+ stubFetch(() => {
+ if (first) {
+ first = false
+ return { status: 429, body: {} }
+ }
+ return { body: order }
+ })
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).resolves.toBeDefined()
+ })
+
+ it('tells the user to try again when still rate limited', async () => {
+ stubFetch(() => ({ status: 429, body: {} }))
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/busy/i)
+ })
+
+ it('tells the user to try again when Jupiter does not answer', async () => {
+ vi.stubGlobal('fetch', async () => {
+ throw new DOMException('timed out', 'TimeoutError')
+ })
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses a request that is not a swap', async () => {
+ answering({})
+ await expect(
+ venue().prepare({ kind: 'supply', amount: '1', tokenIn: 'USDC' }, CAPTURE_ACCOUNT)
+ ).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('jupiter lend', () => {
+ const tokens = [
+ { assetAddress: USDC, supplyRate: '504', rewardsRate: '25' },
+ { assetAddress: USDT, supplyRate: '493', rewardsRate: '0' },
+ ]
+ const request = { kind: 'supply', amount: '1', tokenIn: 'USDC' } as const
+
+ function venue() {
+ return createJupiterLendVenue({ env: {}, retryDelayMs: 0 })
+ }
+
+ function answering(rates: unknown = tokens, status = 200) {
+ return stubFetch((url) =>
+ url.endsWith('/earn/tokens')
+ ? { status, body: rates }
+ : { body: { transaction: JUPITER_LEND_USDC_DEPOSIT } }
+ )
+ }
+
+ it('builds a deposit from a real captured transaction and shows the rate', async () => {
+ const { calls } = answering()
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared).toMatchObject({
+ venueId: 'jupiter-lend',
+ kind: 'supply',
+ pay: { amount: '1', symbol: 'USDC' },
+ apy: { basePercent: 5.04, rewardPercent: 0.25 },
+ signing: { family: 'solana' },
+ })
+ const deposit = calls.find((c) => c.url.endsWith('/earn/deposit'))
+ expect(JSON.parse(String(deposit?.init?.body))).toEqual({
+ asset: USDC,
+ amount: '1000000',
+ signer: CAPTURE_ACCOUNT,
+ })
+ })
+
+ it('opens the receipt token account first, because the live deposit fails without one', async () => {
+ answering()
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const { transaction } = prepared.signing
+
+ const decoded = decodeSolanaTransaction(transaction)
+ const original = decodeSolanaTransaction(JUPITER_LEND_USDC_DEPOSIT)
+ expect(decoded.instructions).toHaveLength(original.instructions.length + 1)
+ const [create, deposit] = decoded.instructions
+ const accountAt = (i: number | undefined) => decoded.staticAccounts[i as number]
+ expect(accountAt(create?.programIndex)).toBe('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL')
+ expect(Array.from(create?.data ?? [])).toEqual([1])
+ expect(accountAt(create?.accounts[0])).toBe(CAPTURE_ACCOUNT)
+ expect(accountAt(create?.accounts[1])).toBe('8bPdc8BkLcmkynzQMaQa11te8AobeWkQNjzVV8qeKJpY')
+ expect(accountAt(create?.accounts[2])).toBe(CAPTURE_ACCOUNT)
+ expect(accountAt(create?.accounts[3])).toBe('9BEcn9aPEmhSPbPQeFGjidRiEKki46fVQDyPpSQXPA2D')
+ expect(accountAt(deposit?.programIndex)).toBe(JUPITER_LEND_PROGRAM)
+ expect(Array.from(deposit?.data ?? [])).toEqual(
+ Array.from(original.instructions[0]?.data ?? [])
+ )
+ expect(decoded.blockhash).toBe(original.blockhash)
+ expect(decoded.feePayerSignature).toBeNull()
+
+ expect(() => venue().assertSafe(prepared.signing, CAPTURE_ACCOUNT)).not.toThrow()
+ expect(prepared.notes.join(' ')).toMatch(/token account/i)
+ })
+
+ it('refuses a deposit whose recipient is not the wallet’s own token account', async () => {
+ stubFetch((url) =>
+ url.endsWith('/earn/tokens')
+ ? { body: tokens }
+ : { body: { transaction: lendDepositWithRecipientSwapped() } }
+ )
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/recipient|your own/i)
+ })
+
+ it('says the API is beta and rates change, and promises nothing', async () => {
+ answering()
+ const notes = (await venue().prepare(request, CAPTURE_ACCOUNT)).notes.join(' ')
+ expect(notes).toMatch(/beta/i)
+ expect(notes).toMatch(/rate/i)
+ expect(notes).not.toMatch(/guaranteed returns|risk[- ]free/i)
+ })
+
+ it('reads the rate in the fraction form the docs show as well as live basis points', async () => {
+ answering([{ assetAddress: USDC, supplyRate: '0.025', rewardsRate: '0.035' }])
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared.apy?.basePercent).toBeCloseTo(2.5)
+ expect(prepared.apy?.rewardPercent).toBeCloseTo(3.5)
+ })
+
+ it('still builds, and says so, when the rate cannot be read', async () => {
+ answering({}, 500)
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared.apy).toBeUndefined()
+ expect(prepared.notes.join(' ')).toMatch(/current rate/i)
+ })
+
+ it('refuses a token this venue does not take', async () => {
+ const { calls } = answering()
+ await expect(
+ venue().prepare({ kind: 'supply', amount: '1', tokenIn: 'SOL' }, CAPTURE_ACCOUNT)
+ ).rejects.toThrow(/USDC/)
+ expect(calls).toHaveLength(0)
+ })
+
+ it('refuses a deposit whose amount differs from the request', async () => {
+ answering()
+ await expect(venue().prepare({ ...request, amount: '2' }, CAPTURE_ACCOUNT)).rejects.toThrow(
+ /amount/i
+ )
+ })
+
+ it('refuses when no transaction comes back', async () => {
+ stubFetch((url) =>
+ url.endsWith('/earn/tokens') ? { body: tokens } : { status: 400, body: { error: 'x' } }
+ )
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('kamino', () => {
+ const KVAULT = 'KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd'
+ const vault = (address: string, name: string, usdc: number, mint = USDC, programId = KVAULT) => ({
+ address,
+ programId,
+ state: {
+ name,
+ tokenMint: mint,
+ tokenMintDecimals: 6,
+ prevAum: String(usdc * 1e6),
+ minDepositAmount: '100000',
+ },
+ })
+ const VAULTS = [
+ vault('SmallHighApy1111111111111111111111111111111', 'Spiky', 5_000_000),
+ vault('DWSXb18xZApz29vnQpgR2m6MynCT7PznaXt7Ut7M7KaP', 'RWA USDC', 51_000_000),
+ vault('SolVault111111111111111111111111111111111111', 'Big SOL', 900_000_000, SOL),
+ vault(
+ 'Staging11111111111111111111111111111111111111',
+ 'Staging',
+ 80_000_000,
+ USDC,
+ 'stKvQfwRsQiKnLtMNVLHKS3exFJmZFsgfzBPWHECUYK'
+ ),
+ ]
+ const METRICS = {
+ apy: '0.0577',
+ apyFarmRewards: '0.01',
+ apyIncentives: '0',
+ apyReservesIncentives: '0.002',
+ }
+ const request = { kind: 'supply', amount: '1', tokenIn: 'USDC' } as const
+
+ function venue() {
+ return createKaminoVenue({ cacheMs: 0 })
+ }
+
+ function answering(vaults: unknown = VAULTS, metricsStatus = 200) {
+ return stubFetch((url) => {
+ if (url.endsWith('/kvaults/vaults')) return { body: vaults }
+ if (url.endsWith('/metrics')) {
+ // The small vault has the highest rate, which must not affect the choice.
+ return url.includes('SmallHighApy')
+ ? { body: { ...METRICS, apy: '0.40' } }
+ : { status: metricsStatus, body: METRICS }
+ }
+ return { body: { transaction: KAMINO_USDC_DEPOSIT } }
+ })
+ }
+
+ it('chooses the USDC vault with the highest TVL, not the highest rate, and names it', async () => {
+ const { calls } = answering()
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ const deposit = calls.find((c) => c.url.endsWith('/ktx/kvault/deposit'))
+ expect(JSON.parse(String(deposit?.init?.body))).toEqual({
+ wallet: CAPTURE_ACCOUNT,
+ kvault: 'DWSXb18xZApz29vnQpgR2m6MynCT7PznaXt7Ut7M7KaP',
+ amount: '1',
+ })
+ expect(prepared.summary).toContain('RWA USDC')
+ expect(prepared.venueId).toBe('kamino')
+ expect(prepared.apy?.basePercent).toBeCloseTo(5.77)
+ expect(prepared.apy?.rewardPercent).toBeCloseTo(1.2)
+ expect(prepared.signing).toEqual({ family: 'solana', transaction: KAMINO_USDC_DEPOSIT })
+ })
+
+ it('leaves out a reward part when there is none', async () => {
+ stubFetch((url) => {
+ if (url.endsWith('/kvaults/vaults')) return { body: VAULTS }
+ if (url.endsWith('/metrics')) {
+ return { body: { apy: '0.05', apyFarmRewards: '0', apyIncentives: '0' } }
+ }
+ return { body: { transaction: KAMINO_USDC_DEPOSIT } }
+ })
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared.apy).toEqual({ basePercent: 5 })
+ })
+
+ it('still builds, and says so, when the rate cannot be read', async () => {
+ answering(VAULTS, 500)
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared.apy).toBeUndefined()
+ expect(prepared.notes.join(' ')).toMatch(/current rate/i)
+ })
+
+ it('cleans an on-chain vault name before showing it', async () => {
+ answering([vault('DWSXb18xZApz29vnQpgR2m6MynCT7PznaXt7Ut7M7KaP', 'Vault\u0000x ', 1)])
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared.summary).not.toMatch(/[<>\u0000]/)
+ })
+
+ it('refuses anything but USDC', async () => {
+ const { calls } = answering()
+ await expect(
+ venue().prepare({ kind: 'supply', amount: '1', tokenIn: 'USDT' }, CAPTURE_ACCOUNT)
+ ).rejects.toThrow(/USDC/)
+ expect(calls).toHaveLength(0)
+ })
+
+ it('refuses less than the vault’s minimum deposit', async () => {
+ answering()
+ await expect(venue().prepare({ ...request, amount: '0.01' }, CAPTURE_ACCOUNT)).rejects.toThrow(
+ /at least 0\.1 USDC/
+ )
+ })
+
+ it('refuses when no USDC vault is listed', async () => {
+ answering([vault('SolVault111111111111111111111111111111111111', 'Big SOL', 9, SOL)])
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/USDC vault/)
+ })
+
+ it('refuses a deposit whose amount differs from the request', async () => {
+ answering()
+ await expect(venue().prepare({ ...request, amount: '2' }, CAPTURE_ACCOUNT)).rejects.toThrow(
+ /amount/i
+ )
+ })
+
+ it('warns about the curator and withdrawals, and promises nothing', async () => {
+ answering()
+ const notes = (await venue().prepare(request, CAPTURE_ACCOUNT)).notes
+ expect(notes.length).toBeLessThanOrEqual(3)
+ expect(notes.join(' ')).toMatch(/curator/i)
+ expect(notes.join(' ')).toMatch(/withdraw/i)
+ })
+})
+
+describe('lulo', () => {
+ const request = { kind: 'supply', amount: '1', tokenIn: 'USDC' } as const
+
+ function venue() {
+ return createLuloVenue({ apiKey: 'lulo-key' })
+ }
+
+ function answering(deposit: unknown = { transaction: LULO_USDC_DEPOSIT }, status = 200) {
+ return stubFetch((url) =>
+ url.includes('rates.getRates')
+ ? { body: { protected: { CURRENT: 4.26 }, regular: { CURRENT: 8.8 } } }
+ : { status, body: deposit }
+ )
+ }
+
+ it('builds from a real captured transaction, in display units, with the key', async () => {
+ const { calls } = answering()
+ const prepared = await venue().prepare(request, CAPTURE_ACCOUNT)
+ expect(prepared).toMatchObject({
+ venueId: 'lulo',
+ kind: 'supply',
+ apy: { basePercent: 4.26 },
+ signing: { family: 'solana', transaction: LULO_USDC_DEPOSIT },
+ })
+ const deposit = calls.find((c) => c.url.endsWith('/v1/generate.transactions.deposit'))
+ // Not base units, despite the docs' example: 1000000 here built a $1,000,000 deposit.
+ expect(JSON.parse(String(deposit?.init?.body))).toEqual({
+ owner: CAPTURE_ACCOUNT,
+ mintAddress: USDC,
+ protectedAmount: 1,
+ })
+ expect(new Headers(deposit?.init?.headers).get('x-api-key')).toBe('lulo-key')
+ expect(() => venue().assertSafe(prepared.signing, CAPTURE_ACCOUNT)).not.toThrow()
+ })
+
+ it('says the cover is depositor-funded first loss and not insurance', async () => {
+ answering()
+ const notes = (await venue().prepare(request, CAPTURE_ACCOUNT)).notes.join(' ')
+ expect(notes).toMatch(/first-loss/i)
+ expect(notes).toMatch(/not insurance/i)
+ })
+
+ it('refuses a deposit whose amount differs from the request', async () => {
+ answering()
+ await expect(venue().prepare({ ...request, amount: '2' }, CAPTURE_ACCOUNT)).rejects.toThrow(
+ /amount/i
+ )
+ })
+
+ it('refuses when Lulo’s own simulation failed', async () => {
+ answering({ transaction: LULO_USDC_DEPOSIT, simulateError: '{"InstructionError":[1]}' })
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(/balance/i)
+ })
+
+ it('refuses anything but USDC', async () => {
+ const { calls } = answering()
+ await expect(
+ venue().prepare({ kind: 'supply', amount: '1', tokenIn: 'SOL' }, CAPTURE_ACCOUNT)
+ ).rejects.toThrow(/USDC/)
+ expect(calls).toHaveLength(0)
+ })
+
+ it('refuses when the key is rejected', async () => {
+ answering({ error: 'nope' }, 401)
+ await expect(venue().prepare(request, CAPTURE_ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('does not let its policy pass another venue’s transaction', () => {
+ expect(() =>
+ venue().assertSafe({ family: 'solana', transaction: KAMINO_USDC_DEPOSIT }, CAPTURE_ACCOUNT)
+ ).toThrow(/program/i)
+ })
+
+ it('appears only when a key is configured', () => {
+ expect(buildSolanaVenues({ LULO_API_KEY: ' ' }).map((v) => v.id)).not.toContain('lulo')
+ expect(buildSolanaVenues({ LULO_API_KEY: 'k' }).map((v) => v.id)).toContain('lulo')
+ })
+})
+
+describe('module', () => {
+ it('offers the three venues that need no key', () => {
+ expect(buildSolanaVenues({}).map((v) => v.id)).toEqual(['jupiter', 'jupiter-lend', 'kamino'])
+ expect(solanaModule.defaultVenue).toMatchObject({ swap: 'jupiter', supply: 'jupiter-lend' })
+ expect(solanaModule.chain).toBe('solana')
+ })
+
+ it('lets only Jupiter land its own transactions', () => {
+ const [swap, lend, kamino] = buildSolanaVenues({})
+ expect(swap?.submit).toBeTypeOf('function')
+ expect(lend?.submit).toBeUndefined()
+ 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,
+ {}
+ )
+ ).rejects.toThrow(/can't swap here/)
+ })
+
+ it('routes a supply named by the words a person would use', async () => {
+ const seen: string[] = []
+ const chainApi = {
+ ...solanaModule,
+ priceUsd: async () => 1,
+ venues: solanaModule.venues.map((v) => ({
+ ...v,
+ prepare: async () => {
+ seen.push(v.id)
+ throw new ChainActionRefused('stop')
+ },
+ })),
+ }
+ for (const venue of ['Kamino', 'Jupiter Lend']) {
+ await prepareChainAction(
+ chainApi,
+ { kind: 'supply', amount: '1', tokenIn: 'USDC', venue },
+ CAPTURE_ACCOUNT,
+ {}
+ ).catch(() => undefined)
+ }
+ expect(seen).toEqual(['kamino', 'jupiter-lend'])
+ })
+})
+
+describe('venues page', () => {
+ it('lists every row of the report', () => {
+ expect(solanaVenues).toHaveLength(14)
+ expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(14)
+ for (const v of solanaVenues) {
+ expect(v.family).toBe('solana')
+ expect(v.chains).toEqual(['Solana'])
+ expect(v.url).toMatch(/^https:\/\//)
+ expect(v.bestFor.length).toBeGreaterThan(0)
+ }
+ })
+
+ it('claims execution only for what is wired and on by default', () => {
+ const executes = solanaVenues.filter((v) => v.integration === 'executes')
+ const wired = buildSolanaVenues({}).map((v) => v.id)
+ for (const v of executes) {
+ expect(v.capability).toBeTruthy()
+ // OnRe has no venue of its own: it is bought through the Jupiter swap venue.
+ expect(v.id === 'onre' ? wired.includes('jupiter') : wired.includes(v.id)).toBe(true)
+ }
+ for (const id of wired) expect(executes.map((v) => v.id)).toContain(id)
+ })
+
+ it('keeps Linq on Solana listed', () => {
+ expect(solanaVenues.find((v) => v.name.startsWith('Linq'))?.integration ?? 'listed').toBe(
+ 'listed'
+ )
+ })
+
+ it('does not collide with the ids of other chains’ venues', () => {
+ for (const id of ['phoenix', 'linq', 'blend', 'soroswap']) {
+ expect(solanaVenues.map((v) => v.id)).not.toContain(id)
+ }
+ })
+})
+
+describe('capabilities', () => {
+ it('has one entry per 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)
+ const swap = solanaCapabilities.find((c) => c.kind === 'swap')!
+ expect(parseChainAction(`swap 10 USDC to ${swap.symbol}`)).toMatchObject({
+ kind: 'swap',
+ tokenOut: swap.symbol,
+ })
+ const supply = solanaCapabilities.find((c) => c.kind === 'supply')!
+ expect(parseChainAction(`supply 10 USDC on ${supply.venue}`)).toMatchObject({
+ kind: 'supply',
+ venue: supply.venue,
+ })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/sui-cetus.test.ts b/apps/dapp/lib/__tests__/sui-cetus.test.ts
new file mode 100644
index 00000000..8753ebec
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-cetus.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ cetusOverlay,
+ judgeRoute,
+ MAX_PRICE_IMPACT,
+ MAX_VALUE_LOSS,
+ SLIPPAGE_BPS,
+} from '../chains/sui/cetus-rules'
+import { ChainActionRefused } from '../chains/types'
+import { SUI_TOKENS } from '../chains/sui/tokens'
+
+const [SUI, USDC] = [
+ SUI_TOKENS.find((t) => t.symbol === 'SUI'),
+ SUI_TOKENS.find((t) => t.symbol === 'USDC'),
+] as const
+if (SUI === undefined || USDC === undefined) throw new Error('token list changed')
+
+const FINE = {
+ requestedIn: '100000000',
+ amountIn: '100000000',
+ amountOut: '111826266892',
+ deviationRatio: '0.00000921',
+ tokenIn: USDC,
+ tokenOut: SUI,
+ priceIn: 1,
+ priceOut: 0.8954,
+}
+
+describe('judging a route before it can reach a wallet', () => {
+ it('passes a healthy route', () => {
+ expect(() => judgeRoute(FINE)).not.toThrow()
+ })
+
+ it('has limits no looser than the brief', () => {
+ expect(MAX_PRICE_IMPACT).toBeLessThanOrEqual(0.03)
+ expect(MAX_VALUE_LOSS).toBeLessThanOrEqual(0.05)
+ expect(SLIPPAGE_BPS).toBeLessThanOrEqual(100)
+ })
+
+ it('refuses price impact over 3%, whichever way the API signs it', () => {
+ for (const deviationRatio of ['0.0301', '-0.0301']) {
+ expect(() => judgeRoute({ ...FINE, deviationRatio })).toThrow(ChainActionRefused)
+ expect(() => judgeRoute({ ...FINE, deviationRatio })).toThrow(/move the price/)
+ }
+ })
+
+ it('refuses a price impact it cannot read', () => {
+ expect(() => judgeRoute({ ...FINE, deviationRatio: 'nope' })).toThrow(ChainActionRefused)
+ expect(() => judgeRoute({ ...FINE, deviationRatio: undefined })).toThrow(ChainActionRefused)
+ })
+
+ it('refuses the FDUSD case: a $100K route that returns $28.5K', () => {
+ const fdusd = { symbol: 'FDUSD', id: '0xf::fdusd::FDUSD', decimals: 6 }
+ expect(() =>
+ judgeRoute({
+ ...FINE,
+ requestedIn: '100000000000',
+ amountIn: '100000000000',
+ amountOut: '28532000000',
+ deviationRatio: '0.001',
+ tokenOut: fdusd,
+ priceOut: 1,
+ })
+ ).toThrow(/worse than/)
+ })
+
+ it('refuses just over 5% worse and allows just under', () => {
+ const atValue = (dollars: number): Parameters[0] => ({
+ ...FINE,
+ requestedIn: '100000000',
+ amountIn: '100000000',
+ tokenOut: USDC,
+ amountOut: String(dollars * 1e6),
+ priceOut: 1,
+ })
+ expect(() => judgeRoute(atValue(94.9))).toThrow(ChainActionRefused)
+ expect(() => judgeRoute(atValue(95.1))).not.toThrow()
+ })
+
+ it('refuses when either side has no live price, rather than guessing', () => {
+ expect(() => judgeRoute({ ...FINE, priceOut: undefined })).toThrow(/live price/)
+ expect(() => judgeRoute({ ...FINE, priceIn: undefined })).toThrow(/live price/)
+ })
+
+ it('refuses a route quoted for less than was asked', () => {
+ expect(() => judgeRoute({ ...FINE, amountIn: '50000000' })).toThrow(/amount/)
+ })
+
+ it('refuses a route that pays nothing', () => {
+ expect(() => judgeRoute({ ...FINE, amountOut: '0' })).toThrow(ChainActionRefused)
+ })
+})
+
+describe('the overlay fee', () => {
+ const RECEIVER = '0x1111111111111111111111111111111111111111111111111111111111111111'
+
+ it('is off unless a receiver is set', () => {
+ expect(cetusOverlay({})).toBeUndefined()
+ expect(cetusOverlay({ CETUS_OVERLAY_FEE_BPS: '10' })).toBeUndefined()
+ })
+
+ it('is off with a receiver but no rate, so nobody is charged by default', () => {
+ expect(cetusOverlay({ CETUS_OVERLAY_FEE_RECEIVER: RECEIVER })).toBeUndefined()
+ })
+
+ it('is off when the receiver is not an address', () => {
+ expect(
+ cetusOverlay({ CETUS_OVERLAY_FEE_RECEIVER: 'nope', CETUS_OVERLAY_FEE_BPS: '10' })
+ ).toBeUndefined()
+ })
+
+ it('is off when the rate is out of range or not a whole number', () => {
+ for (const CETUS_OVERLAY_FEE_BPS of ['0', '-1', '101', '1.5', 'x']) {
+ expect(
+ cetusOverlay({ CETUS_OVERLAY_FEE_RECEIVER: RECEIVER, CETUS_OVERLAY_FEE_BPS })
+ ).toBeUndefined()
+ }
+ })
+
+ it('reads a valid receiver and rate', () => {
+ expect(
+ cetusOverlay({ CETUS_OVERLAY_FEE_RECEIVER: RECEIVER, CETUS_OVERLAY_FEE_BPS: '10' })
+ ).toEqual({ receiver: RECEIVER, bps: 10 })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts
new file mode 100644
index 00000000..4e66d732
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-module.test.ts
@@ -0,0 +1,336 @@
+import { describe, expect, it } from 'vitest'
+
+import { naviApy, poolNotes } from '../chains/sui/navi-rules'
+import { mintedAmount, SSUI_TYPE } from '../chains/sui/policies'
+import { priceFromLlama } from '../chains/sui/price'
+import { refusalFromBuildError } from '../chains/sui/client'
+import { submitSui, type SuiExecutor } from '../chains/sui/submit'
+import { SUI_TOKENS } from '../chains/sui/tokens'
+import { suiCapabilities } from '../chains/sui/capabilities'
+import { suiVenues } from '../chains/sui/venues'
+import { suiModule } from '../chains/sui/module'
+import { tokenBySymbol, ChainActionRefused } from '../chains/types'
+import { venues } from '../venues'
+
+const NOW = 1_793_720_000_000
+const token = (symbol: string) => {
+ const found = tokenBySymbol(SUI_TOKENS, symbol)
+ if (found === undefined) throw new Error(`no ${symbol}`)
+ return found
+}
+
+function llama(entry: unknown): typeof fetch {
+ return (async (url: string | URL | Request) => {
+ const key = decodeURIComponent(String(url)).split('/current/')[1]?.split('?')[0] as string
+ return new Response(JSON.stringify({ coins: entry === undefined ? {} : { [key]: entry } }))
+ }) as typeof fetch
+}
+
+describe('the token list', () => {
+ it('holds the exact coin types confirmed on chain', () => {
+ expect(SUI_TOKENS.map((t) => [t.symbol, t.id, t.decimals])).toEqual([
+ ['SUI', '0x2::sui::SUI', 9],
+ ['USDC', '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC', 6],
+ [
+ 'USDsui',
+ '0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::USDSUI',
+ 6,
+ ],
+ ['USDT', '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT', 6],
+ ])
+ })
+
+ it('finds nothing for a lookalike symbol', () => {
+ expect(tokenBySymbol(SUI_TOKENS, 'FDUSD')).toBeUndefined()
+ expect(tokenBySymbol(SUI_TOKENS, 'wUSDC')).toBeUndefined()
+ })
+})
+
+describe('priceUsd', () => {
+ const fresh = { price: 0.9997, timestamp: NOW / 1000 - 60, confidence: 0.99 }
+
+ it('treats a stablecoin the source agrees with as $1', async () => {
+ expect(await priceFromLlama(token('USDC'), llama(fresh), NOW)).toBe(1)
+ })
+
+ it('uses the live price for a stablecoin off its peg', async () => {
+ const off = { ...fresh, price: 0.97 }
+ expect(await priceFromLlama(token('USDT'), llama(off), NOW)).toBe(0.97)
+ })
+
+ it('uses the live price for SUI', async () => {
+ const sui = { ...fresh, price: 0.8954 }
+ expect(await priceFromLlama(token('SUI'), llama(sui), NOW)).toBe(0.8954)
+ })
+
+ it('returns nothing for a stale SUI price', async () => {
+ const stale = { ...fresh, timestamp: NOW / 1000 - 3600 }
+ expect(await priceFromLlama(token('SUI'), llama(stale), NOW)).toBeUndefined()
+ })
+
+ it('returns nothing for a low-confidence price', async () => {
+ const shaky = { ...fresh, confidence: 0.5 }
+ expect(await priceFromLlama(token('SUI'), llama(shaky), NOW)).toBeUndefined()
+ })
+
+ it('returns nothing when the token is missing or the price is not positive', async () => {
+ expect(await priceFromLlama(token('SUI'), llama(undefined), NOW)).toBeUndefined()
+ expect(await priceFromLlama(token('SUI'), llama({ ...fresh, price: 0 }), NOW)).toBeUndefined()
+ })
+
+ it('returns nothing, and does not throw, when the request fails', async () => {
+ const down = (async () => {
+ throw new Error('down')
+ }) as typeof fetch
+ const bad = (async () => new Response('nope', { status: 500 })) as typeof fetch
+ expect(await priceFromLlama(token('SUI'), down, NOW)).toBeUndefined()
+ expect(await priceFromLlama(token('SUI'), bad, NOW)).toBeUndefined()
+ })
+
+ it('never prices a token outside the list', async () => {
+ const other = { symbol: 'USDT', id: '0x778e::usdt::USDT', decimals: 6 }
+ expect(await priceFromLlama(other, llama(fresh), NOW)).toBeUndefined()
+ })
+})
+
+describe('NAVI rate and notes', () => {
+ it('keeps base and reward separate, from NAVI’s own fields', () => {
+ expect(naviApy({ supplyIncentiveApyInfo: { vaultApr: '8.156', boostedApr: '0.334' } })).toEqual(
+ {
+ basePercent: 8.156,
+ rewardPercent: 0.334,
+ }
+ )
+ })
+
+ it('leaves out a reward of zero', () => {
+ expect(naviApy({ supplyIncentiveApyInfo: { vaultApr: '5', boostedApr: '0' } })).toEqual({
+ basePercent: 5,
+ })
+ })
+
+ it('shows no rate rather than a made-up one', () => {
+ expect(naviApy({ supplyIncentiveApyInfo: { vaultApr: 'x', boostedApr: '1' } })).toBeUndefined()
+ expect(naviApy({})).toBeUndefined()
+ })
+
+ it('warns about moving rates and how much can be withdrawn, and promises nothing', () => {
+ const notes = poolNotes({ availableUsdc: 2_237_104.66 })
+ expect(notes.length).toBeGreaterThanOrEqual(1)
+ expect(notes.length).toBeLessThanOrEqual(3)
+ expect(notes.join(' ')).toMatch(/\$2\.2M/)
+ expect(notes.join(' ')).toMatch(/not guaranteed/i)
+ expect(notes.join(' ')).not.toMatch(/guaranteed return|risk.free|safe yield/i)
+ })
+})
+
+describe('what a stake mints', () => {
+ const ME = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+ const SUI_CHANGE = { coinType: '0x2::sui::SUI', address: ME, amount: '-1000000000' }
+
+ it('reads the sSUI the dry run paid this account', () => {
+ expect(
+ mintedAmount([SUI_CHANGE, { coinType: SSUI_TYPE, address: ME, amount: '963360812' }], ME)
+ ).toBe('963360812')
+ })
+
+ it('finds nothing when it was paid to someone else, or nothing was paid', () => {
+ const other = '0x000000000000000000000000000000000000000000000000000000000000dead'
+ expect(mintedAmount([{ coinType: SSUI_TYPE, address: other, amount: '5' }], ME)).toBeUndefined()
+ expect(mintedAmount([{ coinType: SSUI_TYPE, address: ME, amount: '-5' }], ME)).toBeUndefined()
+ expect(mintedAmount([SUI_CHANGE], ME)).toBeUndefined()
+ expect(mintedAmount(undefined, ME)).toBeUndefined()
+ })
+})
+
+describe('reading a failed build', () => {
+ it('reads a dry run that ran out of coin as a short balance', () => {
+ const e = refusalFromBuildError(
+ new Error('Transaction resolution failed: InsufficientCoinBalance in command 0'),
+ 'SUI'
+ )
+ expect(e?.message).toMatch(/enough SUI/)
+ })
+
+ it('reads the SDK gas selection failure as needing SUI for the fee', () => {
+ const e = refusalFromBuildError(
+ new Error(
+ 'Unable to perform gas selection due to insufficient SUI balance (in address balance or coins) for account 0x1234 to satisfy required budget 1188000.'
+ ),
+ 'USDC'
+ )
+ expect(e?.message).toMatch(/network fee/)
+ })
+
+ it('says when the account is short of the token', () => {
+ const e = refusalFromBuildError(
+ new Error(
+ 'Insufficient balance of 0x..::usdc::USDC for owner 0x1. Required: 1, Available: 0'
+ ),
+ 'USDC'
+ )
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e?.message).toMatch(/enough USDC/)
+ })
+
+ it('says when there is no SUI for the network fee', () => {
+ const e = refusalFromBuildError(
+ new Error('No valid gas coins found for the transaction.'),
+ 'USDC'
+ )
+ expect(e?.message).toMatch(/SUI/)
+ })
+
+ it('says when the dry run fails', () => {
+ const e = refusalFromBuildError(
+ new Error('Transaction resolution failed: MoveAbort in 2nd command, abort code: 1'),
+ 'USDC'
+ )
+ expect(e?.message).toMatch(/would fail/)
+ })
+
+ it('leaves an outage for the route to report as an outage', () => {
+ expect(refusalFromBuildError(new Error('fetch failed'), 'USDC')).toBeUndefined()
+ })
+})
+
+describe('submitting signed bytes', () => {
+ const signed = { family: 'sui' as const, transaction: 'AAAA', signature: 'BBBB' }
+ const input = { account: '0x1', venueId: 'cetus', signed }
+
+ function executor(run: () => unknown): SuiExecutor & { calls: unknown[] } {
+ const calls: unknown[] = []
+ return {
+ calls,
+ executeTransaction: async (options: unknown) => {
+ calls.push(options)
+ return run() as never
+ },
+ }
+ }
+
+ it('sends the bytes and signature, then reports the digest and explorer link', async () => {
+ const client = executor(() => ({
+ $kind: 'Transaction',
+ Transaction: { digest: 'DIGEST', effects: { status: { success: true, error: null } } },
+ }))
+ expect(await submitSui(input, client)).toEqual({
+ ok: true,
+ hash: 'DIGEST',
+ explorerUrl: 'https://suiscan.xyz/mainnet/tx/DIGEST',
+ })
+ expect(client.calls[0]).toMatchObject({
+ signatures: ['BBBB'],
+ include: { effects: true },
+ })
+ })
+
+ it('reports a transaction that landed and failed, in plain words', async () => {
+ const client = executor(() => ({
+ $kind: 'FailedTransaction',
+ FailedTransaction: {
+ digest: 'DIGEST',
+ effects: { status: { success: false, error: { message: 'MoveAbort(1)' } } },
+ },
+ }))
+ const result = await submitSui(input, client)
+ expect(result.ok).toBe(false)
+ if (!result.ok) {
+ expect(result.error).toMatch(/failed/)
+ expect(result.error).toMatch(/network fee/)
+ expect(result.error).not.toMatch(/MoveAbort/)
+ }
+ })
+
+ it('does not call an execution with no effects a success', async () => {
+ const client = executor(() => ({ $kind: 'Transaction', Transaction: { digest: 'D' } }))
+ await expect(submitSui(input, client)).rejects.toThrow()
+ })
+
+ it('refuses anything but Sui bytes', async () => {
+ const client = executor(() => {
+ throw new Error('should not send')
+ })
+ const result = await submitSui({ ...input, signed: { family: 'near', hashes: ['x'] } }, client)
+ expect(result.ok).toBe(false)
+ expect(client.calls).toHaveLength(0)
+ })
+
+ it('tells someone to rebuild when their coins changed', async () => {
+ const client = executor(() => {
+ throw new Error('Object 0xabc not available for consumption, current version: 5')
+ })
+ const result = await submitSui(input, client)
+ expect(result).toMatchObject({ ok: false })
+ if (!result.ok) expect(result.error).toMatch(/again/)
+ })
+
+ it('says nothing moved when the network rejects it outright', async () => {
+ const client = executor(() => {
+ throw Object.assign(new Error('Invalid user signature'), { code: 'INVALID_ARGUMENT' })
+ })
+ const result = await submitSui(input, client)
+ expect(result).toMatchObject({ ok: false })
+ if (!result.ok) expect(result.error).toMatch(/Nothing moved/)
+ })
+
+ it('never claims to know the outcome after a timeout, so the route can say to check', async () => {
+ const client = executor(() => {
+ throw Object.assign(new Error('deadline exceeded'), { code: 'DEADLINE_EXCEEDED' })
+ })
+ await expect(submitSui(input, client)).rejects.toThrow(/deadline/)
+ })
+})
+
+describe('the module', () => {
+ it('wires only swap, supply and stake, to Cetus, NAVI and SpringSui', () => {
+ expect(suiModule.venues.map((v) => [v.id, v.kinds])).toEqual([
+ ['cetus', ['swap']],
+ ['navi', ['supply']],
+ ['springsui', ['stake']],
+ ])
+ expect(suiModule.defaultVenue).toEqual({ swap: 'cetus', supply: 'navi', stake: 'springsui' })
+ })
+
+ it('offers one capability per wired kind', () => {
+ expect(suiCapabilities).toEqual([
+ { kind: 'swap', venue: 'Cetus', symbol: 'SUI' },
+ { kind: 'supply', venue: 'NAVI', symbol: 'USDC' },
+ { kind: 'stake', venue: 'SpringSui', symbol: 'SUI' },
+ ])
+ })
+})
+
+describe('the venue list', () => {
+ it('has every row of the report', () => {
+ expect(suiVenues).toHaveLength(14)
+ expect(new Set(suiVenues.map((v) => v.id)).size).toBe(14)
+ })
+
+ it('is all Sui, with a real link and an honest reason', () => {
+ for (const venue of suiVenues) {
+ expect(venue.family).toBe('sui')
+ expect(venue.chains).toEqual(['Sui'])
+ expect(venue.url).toMatch(/^https:\/\//)
+ expect(venue.bestFor.length).toBeGreaterThan(0)
+ }
+ })
+
+ it('claims execution only for what is wired, each with a capability', () => {
+ const wired = suiModule.venues.map((v) => v.id).sort()
+ const claimed = suiVenues.filter((v) => v.integration === 'executes')
+ expect(claimed.map((v) => v.id).sort()).toEqual(wired)
+ for (const venue of claimed) expect(venue.capability).toBeTruthy()
+ })
+
+ it('leaves Linq listed', () => {
+ const linq = suiVenues.find((v) => v.name.startsWith('Linq'))
+ expect(linq?.integration).toBeUndefined()
+ expect(linq?.category).toBe('offramp')
+ })
+
+ it('does not collide with an id on another chain', () => {
+ const ids = venues.map((v) => v.id)
+ expect(new Set(ids).size).toBe(ids.length)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/sui-route.live.test.ts b/apps/dapp/lib/__tests__/sui-route.live.test.ts
new file mode 100644
index 00000000..fabf8e3d
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-route.live.test.ts
@@ -0,0 +1,118 @@
+import { Transaction } from '@mysten/sui/transactions'
+import { describe, expect, it } from 'vitest'
+
+import { suiModule } from '../chains/sui/module'
+import type { PreparedAction } from '../chains/types'
+
+/**
+ * The prepare route on a running app, which is what proves these packages survive
+ * Next's bundler. It only runs when pointed at a server, and it only builds:
+ * nothing is signed or sent.
+ *
+ * SUI_ROUTE_BASE=https://localhost:3001 NODE_TLS_REJECT_UNAUTHORIZED=0 \
+ * pnpm exec vitest run lib/__tests__/sui-route.live.test.ts
+ */
+
+const BASE = process.env['SUI_ROUTE_BASE']
+const SKIP = process.env['SKIP_LIVE'] === '1' || BASE === undefined
+const ACCOUNT = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const TIMEOUT = 90_000
+
+async function post(
+ request: Record,
+ account = ACCOUNT
+): Promise<{ status: number; body: { prepared?: PreparedAction; error?: string } }> {
+ const response = await fetch(`${BASE}/api/chain/sui/prepare`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ account, request }),
+ signal: AbortSignal.timeout(TIMEOUT),
+ })
+ return { status: response.status, body: (await response.json()) as never }
+}
+
+describe.skipIf(SKIP)('the Sui prepare route', () => {
+ it.each([
+ ['cetus', { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SUI' }],
+ ['cetus', { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'USDsui', venue: 'cetus' }],
+ ['navi', { kind: 'supply', amount: '1', tokenIn: 'USDC' }],
+ ['navi', { kind: 'supply', amount: '1', tokenIn: 'USDC', venue: 'navi' }],
+ ['springsui', { kind: 'stake', amount: '1', tokenIn: 'SUI' }],
+ ])(
+ '%s returns something that parses and passes the safety check',
+ async (venueId, request) => {
+ const { status, body } = await post(request)
+ expect(body.error).toBeUndefined()
+ expect(status).toBe(200)
+ const prepared = body.prepared as PreparedAction
+ expect(prepared.venueId).toBe(venueId)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ expect(Transaction.from(prepared.signing.transaction).getData().sender).toBe(ACCOUNT)
+ const venue = suiModule.venues.find((v) => v.id === venueId)
+ expect(() => venue?.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ },
+ TIMEOUT
+ )
+
+ it.each([
+ [
+ 'an unknown token',
+ { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'FDUSD' },
+ /verified/,
+ ],
+ ['a token in that is unknown', { kind: 'supply', amount: '1', tokenIn: 'SCAM' }, /verified/],
+ [
+ 'over the cap',
+ { kind: 'swap', amount: '501', tokenIn: 'USDC', tokenOut: 'SUI' },
+ /\$?500|limit|cap/i,
+ ],
+ [
+ 'a named venue that cannot do this',
+ { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SUI', venue: 'navi' },
+ /can't swap/,
+ ],
+ [
+ 'a listed venue that is not wired',
+ { kind: 'supply', amount: '1', tokenIn: 'USDC', venue: 'suilend' },
+ /can't supply/,
+ ],
+ ['staking anything but SUI', { kind: 'stake', amount: '1', tokenIn: 'USDC' }, /stakes SUI/],
+ [
+ 'a listed staker that is not wired',
+ { kind: 'stake', amount: '1', tokenIn: 'SUI', venue: 'haedal' },
+ /can't stake/,
+ ],
+ [
+ 'the same token twice',
+ { kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'usdc' },
+ /same/,
+ ],
+ [
+ 'more precision than the token has',
+ { kind: 'supply', amount: '1.1234567', tokenIn: 'USDC' },
+ /decimal/,
+ ],
+ ['NAVI with another coin', { kind: 'supply', amount: '1', tokenIn: 'USDT' }, /USDC/],
+ ])(
+ 'refuses %s',
+ async (_name, request, reason) => {
+ const { status, body } = await post(request as Record)
+ expect(status).toBe(422)
+ expect(body.error).toMatch(reason)
+ },
+ TIMEOUT
+ )
+
+ it(
+ 'refuses an account from another chain',
+ async () => {
+ const { status, body } = await post(
+ { kind: 'supply', amount: '1', tokenIn: 'USDC' },
+ 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA'
+ )
+ expect(status).toBe(422)
+ expect(body.error).toMatch(/Sui address/)
+ },
+ TIMEOUT
+ )
+})
diff --git a/apps/dapp/lib/__tests__/sui-safety.test.ts b/apps/dapp/lib/__tests__/sui-safety.test.ts
new file mode 100644
index 00000000..4c2ed8a1
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-safety.test.ts
@@ -0,0 +1,336 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, toBase64 } from '@mysten/sui/utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ CAPTURED_CETUS_TO_SUI,
+ CAPTURED_CETUS_TO_USDSUI,
+ CAPTURED_NAVI_DEPOSIT,
+ CAPTURED_SENDER,
+ CAPTURED_SPRINGSUI_STAKE,
+} from '../chains/sui/captured-transactions'
+import { CETUS_POLICY, NAVI_POLICY, SPRINGSUI_POLICY } from '../chains/sui/policies'
+import { assertPtbSafe, type PtbPolicy } from '../chains/sui/safety'
+import { ChainActionRefused } from '../chains/types'
+
+const ME = CAPTURED_SENDER
+const THIEF = '0x000000000000000000000000000000000000000000000000000000000000dead'
+const FOREIGN = '0x00000000000000000000000000000000000000000000000000000000000bad01'
+const CETUS_ROUTER = '0xffd4058af7d6f6d66c335930cced8c91e38ec943e2392232aee27e8127e684e9'
+const USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
+
+function check(transaction: string, policy: PtbPolicy, account: string = ME): void {
+ assertPtbSafe({ family: 'sui', transaction }, account, policy)
+}
+
+async function edited(base: string, edit: (tx: Transaction) => void): Promise {
+ const tx = Transaction.from(base)
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+interface JsonCommand {
+ MoveCall?: { function: string; arguments: { Input?: number }[] }
+}
+interface JsonTx {
+ inputs: { Pure?: { bytes: string } }[]
+ commands: JsonCommand[]
+}
+
+/** Rewrites one pure argument of `new_swap_context_v3` in a captured swap. */
+async function withSwapArgument(base: string, index: number, bytes: Uint8Array): Promise {
+ const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx
+ const call = data.commands.find((c) => c.MoveCall?.function === 'new_swap_context_v3')
+ const input = call?.MoveCall?.arguments[index]?.Input
+ const target = input === undefined ? undefined : data.inputs[input]
+ if (target?.Pure === undefined) throw new Error('no such pure argument')
+ target.Pure.bytes = toBase64(bytes)
+ return toBase64(await Transaction.from(JSON.stringify(data)).build())
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('what the venues really built passes', () => {
+ it.each([
+ ['a swap into SUI', CAPTURED_CETUS_TO_SUI, CETUS_POLICY],
+ ['a swap into USDsui', CAPTURED_CETUS_TO_USDSUI, CETUS_POLICY],
+ ['a NAVI deposit', CAPTURED_NAVI_DEPOSIT, NAVI_POLICY],
+ ['a SpringSui stake', CAPTURED_SPRINGSUI_STAKE, SPRINGSUI_POLICY],
+ ])('%s', (_name, transaction, policy) => {
+ expect(() => check(transaction, policy)).not.toThrow()
+ })
+
+ it('accepts a differently cased, unpadded form of the account', () => {
+ expect(() =>
+ check(CAPTURED_NAVI_DEPOSIT, NAVI_POLICY, ME.toUpperCase().replace('0X', '0x'))
+ ).not.toThrow()
+ })
+})
+
+describe('the signer', () => {
+ it('refuses a transaction sent by someone else', async () => {
+ const tampered = await edited(CAPTURED_CETUS_TO_SUI, (tx) => tx.setSender(THIEF))
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(ChainActionRefused)
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/different account/)
+ })
+
+ it('refuses a captured transaction checked against a different account', () => {
+ expect(() => check(CAPTURED_NAVI_DEPOSIT, NAVI_POLICY, THIEF)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses an account that is not a Sui address', () => {
+ expect(() =>
+ check(
+ CAPTURED_NAVI_DEPOSIT,
+ NAVI_POLICY,
+ 'GAJ74DIC3252BKBTDCEYTDA2ZYDRDBDT3QMZU5IW2HOZQBYVXMRZZEFA'
+ )
+ ).toThrow(/Sui address/)
+ })
+
+ it('refuses gas paid by someone else', async () => {
+ const tampered = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => tx.setGasOwner(THIEF))
+ expect(() => check(tampered, NAVI_POLICY)).toThrow(/network fee/)
+ })
+})
+
+describe('contracts', () => {
+ it('refuses a package outside the allowlist', async () => {
+ const tampered = await edited(CAPTURED_CETUS_TO_SUI, (tx) => {
+ tx.moveCall({ target: `${FOREIGN}::drain::everything` })
+ })
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(ChainActionRefused)
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/confirmed/)
+ })
+
+ it("refuses one venue's contract in another venue's transaction", () => {
+ expect(() => check(CAPTURED_NAVI_DEPOSIT, CETUS_POLICY)).toThrow(/confirmed/)
+ expect(() => check(CAPTURED_CETUS_TO_SUI, NAVI_POLICY)).toThrow(/confirmed/)
+ })
+
+ it('fails closed on a new package id, which is what a protocol upgrade looks like', async () => {
+ const data = JSON.parse(await Transaction.from(CAPTURED_NAVI_DEPOSIT).toJSON()) as JsonTx
+ const upgraded = JSON.stringify(data).replaceAll(
+ '0x512f28261c1a293f49416d8885b8d5d32bde6dd68a99a0be36fe42b248e6833a',
+ FOREIGN
+ )
+ const transaction = toBase64(await Transaction.from(upgraded).build())
+ expect(() => check(transaction, NAVI_POLICY)).toThrow(/upgraded/)
+ })
+
+ it('refuses a function the venue has but this flow never needs', async () => {
+ const tampered = await edited(CAPTURED_CETUS_TO_SUI, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target: `${CETUS_ROUTER}::router::transfer_balance`,
+ typeArguments: ['0x2::sui::SUI'],
+ arguments: [coin as never, tx.pure.address(THIEF)],
+ })
+ })
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/confirmed/)
+ })
+
+ it('refuses the framework transfer calls, which would bypass the transfer check', async () => {
+ for (const target of ['0x2::transfer::public_transfer', '0x2::pay::split_and_transfer']) {
+ const tampered = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target,
+ typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'],
+ arguments: [coin as never, tx.pure.address(THIEF)],
+ })
+ })
+ expect(() => check(tampered, NAVI_POLICY)).toThrow(/confirmed/)
+ }
+ })
+
+ it('refuses publishing or upgrading a package', async () => {
+ const published = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ tx.publish({ modules: ['AA=='], dependencies: ['0x1', '0x2'] })
+ })
+ expect(() => check(published, NAVI_POLICY)).toThrow(/Publish/)
+ })
+})
+
+describe('where things are sent', () => {
+ it('allows a transfer back to the account', async () => {
+ const fine = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.transferObjects([coin as never], ME)
+ })
+ expect(() => check(fine, NAVI_POLICY)).not.toThrow()
+ })
+
+ it('refuses a transfer to another address', async () => {
+ const tampered = await edited(CAPTURED_CETUS_TO_SUI, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.transferObjects([coin as never], THIEF)
+ })
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(ChainActionRefused)
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/another address/)
+ })
+
+ it('refuses a transfer whose destination is computed, not stated', async () => {
+ const tampered = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ const [computed] = tx.splitCoins(tx.gas, [BigInt(2)])
+ tx.transferObjects([coin as never], computed as never)
+ })
+ expect(() => check(tampered, NAVI_POLICY)).toThrow(/another address/)
+ })
+
+ it('checks every transfer, not only the first', async () => {
+ const tampered = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ const [first, second] = tx.splitCoins(tx.gas, [BigInt(1), BigInt(2)])
+ tx.transferObjects([first as never], ME)
+ tx.transferObjects([second as never], THIEF)
+ })
+ expect(() => check(tampered, NAVI_POLICY)).toThrow(/another address/)
+ })
+
+ it('refuses address-balance funds sent to another address', async () => {
+ const tampered = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target: '0x2::coin::send_funds',
+ typeArguments: ['0x2::sui::SUI'],
+ arguments: [coin as never, tx.pure.address(THIEF)],
+ })
+ })
+ expect(() => check(tampered, NAVI_POLICY)).toThrow(/another address/)
+ })
+
+ it('allows address-balance funds returned to the account', async () => {
+ const fine = await edited(CAPTURED_NAVI_DEPOSIT, (tx) => {
+ const [coin] = tx.splitCoins(tx.gas, [BigInt(1)])
+ tx.moveCall({
+ target: '0x2::coin::send_funds',
+ typeArguments: ['0x2::sui::SUI'],
+ arguments: [coin as never, tx.pure.address(ME)],
+ })
+ })
+ expect(() => check(fine, NAVI_POLICY)).not.toThrow()
+ })
+})
+
+describe('the arguments of a Cetus swap', () => {
+ const u32 = (n: number): Uint8Array => bcs.u32().serialize(n).toBytes()
+ const u64 = (n: number): Uint8Array => bcs.u64().serialize(n).toBytes()
+ const address = (a: string): Uint8Array => bcs.Address.serialize(a).toBytes()
+
+ it('refuses slippage over 100 bps', async () => {
+ const tampered = await withSwapArgument(CAPTURED_CETUS_TO_SUI, 4, u64(10_001))
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/slippage/i)
+ })
+
+ it('allows slippage of exactly 100 bps', async () => {
+ const fine = await withSwapArgument(CAPTURED_CETUS_TO_SUI, 4, u64(10_000))
+ expect(() => check(fine, CETUS_POLICY)).not.toThrow()
+ })
+
+ it('refuses a fee when no fee is configured', async () => {
+ const tampered = await withSwapArgument(CAPTURED_CETUS_TO_SUI, 6, u32(100_000))
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/fee/)
+ })
+
+ it('refuses a configured fee rate paid to the wrong address', async () => {
+ vi.stubEnv('CETUS_OVERLAY_FEE_RECEIVER', ME)
+ vi.stubEnv('CETUS_OVERLAY_FEE_BPS', '10')
+ const rated = await withSwapArgument(CAPTURED_CETUS_TO_SUI, 6, u32(1_000))
+ const tampered = await withSwapArgument(rated, 7, address(THIEF))
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/fee/)
+ })
+
+ it('refuses a fee above the configured rate', async () => {
+ vi.stubEnv('CETUS_OVERLAY_FEE_RECEIVER', ME)
+ vi.stubEnv('CETUS_OVERLAY_FEE_BPS', '10')
+ const rated = await withSwapArgument(CAPTURED_CETUS_TO_SUI, 6, u32(1_001))
+ const tampered = await withSwapArgument(rated, 7, address(ME))
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/fee/)
+ })
+
+ it('allows exactly the configured fee to the configured receiver', async () => {
+ vi.stubEnv('CETUS_OVERLAY_FEE_RECEIVER', ME)
+ vi.stubEnv('CETUS_OVERLAY_FEE_BPS', '10')
+ const rated = await withSwapArgument(CAPTURED_CETUS_TO_SUI, 6, u32(1_000))
+ const fine = await withSwapArgument(rated, 7, address(ME))
+ expect(() => check(fine, CETUS_POLICY)).not.toThrow()
+ })
+
+ it('refuses a swap whose output is a coin outside the token list', async () => {
+ const data = JSON.parse(await Transaction.from(CAPTURED_CETUS_TO_USDSUI).toJSON()) as JsonTx
+ const scam = JSON.stringify(data).replaceAll(
+ '0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::USDSUI',
+ '0x778e2ae6096389cfad3e0b1c6e8080a9287f8792a08d3df1f9025251d820be93::usdt::USDT'
+ )
+ const tampered = toBase64(await Transaction.from(scam).build())
+ expect(() => check(tampered, CETUS_POLICY)).toThrow(/token/)
+ })
+})
+
+describe('the coin a SpringSui stake mints', () => {
+ it('refuses a stake into a liquid staking token other than sSUI', async () => {
+ const data = JSON.parse(await Transaction.from(CAPTURED_SPRINGSUI_STAKE).toJSON()) as JsonTx
+ const scam = JSON.stringify(data).replaceAll(
+ '0x83556891f4a0f233ce7b05cfe7f957d4020492a34f5405b2cb9377d060bef4bf::spring_sui::SPRING_SUI',
+ '0x460c669acd3f294dc4247a6877ec2532340ffde76162ab201e72fe95355830e7::asui::ASUI'
+ )
+ const tampered = toBase64(await Transaction.from(scam).build())
+ expect(() => check(tampered, SPRINGSUI_POLICY)).toThrow(/sSUI/)
+ })
+
+ it('refuses the minted coin sent to someone else', async () => {
+ const data = JSON.parse(await Transaction.from(CAPTURED_SPRINGSUI_STAKE).toJSON()) as JsonTx
+ const me = toBase64(bcs.Address.serialize(ME).toBytes())
+ const thief = toBase64(bcs.Address.serialize(THIEF).toBytes())
+ const tampered = toBase64(
+ await Transaction.from(JSON.stringify(data).replaceAll(me, thief)).build()
+ )
+ expect(() => check(tampered, SPRINGSUI_POLICY)).toThrow(/another address/)
+ })
+})
+
+describe('payloads that are not a transaction', () => {
+ it.each([
+ ['garbage', 'not base64 at all!'],
+ ['an empty string', ''],
+ ['truncated bytes', CAPTURED_NAVI_DEPOSIT.slice(0, 40)],
+ ['the JSON form', '{"version":2,"commands":[]}'],
+ ])('refuses %s with a readable reason', (_name, transaction) => {
+ expect(() => check(transaction, NAVI_POLICY)).toThrow(ChainActionRefused)
+ })
+
+ it('refuses a payload for another chain', () => {
+ expect(() =>
+ assertPtbSafe({ family: 'solana', transaction: CAPTURED_NAVI_DEPOSIT }, ME, NAVI_POLICY)
+ ).toThrow(ChainActionRefused)
+ })
+
+ it('refuses address-balance funds withdrawn from anyone but the sender', () => {
+ // A transaction with a sponsor withdrawal cannot be built by this app, so it is written directly.
+ const tx = Transaction.from(CAPTURED_NAVI_DEPOSIT)
+ const data = tx.getData() as unknown as { inputs: unknown[] }
+ expect(data.inputs.length).toBeGreaterThan(0)
+ const json = {
+ ...(JSON.parse(
+ JSON.stringify(data, (_k, v: unknown) => (typeof v === 'bigint' ? String(v) : v))
+ ) as object),
+ } as { inputs: unknown[] }
+ json.inputs.push({
+ $kind: 'FundsWithdrawal',
+ FundsWithdrawal: {
+ reservation: { $kind: 'MaxAmountU64', MaxAmountU64: '1' },
+ typeArg: { $kind: 'Balance', Balance: USDC },
+ withdrawFrom: { $kind: 'Sponsor', Sponsor: true },
+ },
+ })
+ const bytes = Transaction.from(JSON.stringify(json)).build()
+ return bytes.then((built) => {
+ expect(fromBase64(toBase64(built)).length).toBeGreaterThan(0)
+ expect(() => check(toBase64(built), NAVI_POLICY)).toThrow(/sender/)
+ })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/sui-venues.live.test.ts b/apps/dapp/lib/__tests__/sui-venues.live.test.ts
new file mode 100644
index 00000000..6d673e7c
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-venues.live.test.ts
@@ -0,0 +1,164 @@
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64 } from '@mysten/sui/utils'
+import { getConfig } from '@naviprotocol/lending'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import { suiModule } from '../chains/sui/module'
+import { NAVI_PACKAGE, SPRINGSUI_PACKAGE, SSUI_INFO, SSUI_TYPE } from '../chains/sui/policies'
+import { prepareChainAction } from '../chains/prepare'
+import { ChainActionRefused, type ChainActionRequest } from '../chains/types'
+
+/**
+ * Real quotes and real unsigned transactions against mainnet, read-only. Nothing here
+ * signs or sends: each transaction is only built and dry-run, for a public address
+ * holding USDC and SUI whose key nobody here has. When that address empties these
+ * fail on balance, so swap it for another funded one.
+ */
+
+const SKIP = process.env['SKIP_LIVE'] === '1'
+const ACCOUNT = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const EMPTY = '0x0000000000000000000000000000000000000000000000000000000000001234'
+const ENV = {}
+
+function prepare(request: ChainActionRequest, account = ACCOUNT) {
+ return prepareChainAction(suiModule, request, account, ENV)
+}
+
+async function dryRun(transaction: string): Promise {
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(transaction),
+ include: { effects: true },
+ })
+ return result.Transaction?.effects?.status.success === true
+}
+
+const requests: string[] = []
+const realFetch = globalThis.fetch
+
+beforeAll(() => {
+ vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => {
+ requests.push(input instanceof Request ? input.url : String(input))
+ return realFetch(input, init)
+ })
+})
+
+afterAll(() => {
+ vi.unstubAllGlobals()
+})
+
+describe.skipIf(SKIP)('Sui venues against mainnet', () => {
+ it.each([
+ ['USDC', 'SUI', '1'],
+ ['SUI', 'USDC', '1'],
+ ['USDC', 'USDsui', '1'],
+ ['USDC', 'USDT', '1'],
+ ])(
+ 'Cetus builds %s to %s, passes the safety check, and dry-runs clean',
+ async (tokenIn, tokenOut, amount) => {
+ const prepared = await prepare({ kind: 'swap', amount, tokenIn, tokenOut })
+ expect(prepared.venueId).toBe('cetus')
+ expect(prepared.receive?.symbol).toBe(tokenOut)
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0)
+ expect(prepared.signing.family).toBe('sui')
+ if (prepared.signing.family !== 'sui') return
+
+ const data = Transaction.from(prepared.signing.transaction).getData()
+ expect(data.sender).toBe(ACCOUNT)
+ const venue = suiModule.venues.find((v) => v.id === 'cetus')
+ expect(() => venue?.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(await dryRun(prepared.signing.transaction)).toBe(true)
+ },
+ 60_000
+ )
+
+ it('NAVI builds a USDC deposit, shows its rate, and dry-runs clean', async () => {
+ const prepared = await prepare({ kind: 'supply', amount: '1', tokenIn: 'USDC' })
+ expect(prepared.venueId).toBe('navi')
+ expect(prepared.apy?.basePercent).toBeGreaterThan(0)
+ expect(prepared.notes.join(' ')).toMatch(/not guaranteed/)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+
+ const data = Transaction.from(prepared.signing.transaction).getData()
+ expect(data.sender).toBe(ACCOUNT)
+ const venue = suiModule.venues.find((v) => v.id === 'navi')
+ expect(() => venue?.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(await dryRun(prepared.signing.transaction)).toBe(true)
+ }, 60_000)
+
+ it('Cetus discloses a configured overlay fee, passes the safety check, and dry-runs clean', async () => {
+ vi.stubEnv('CETUS_OVERLAY_FEE_RECEIVER', EMPTY)
+ vi.stubEnv('CETUS_OVERLAY_FEE_BPS', '10')
+ try {
+ const prepared = await prepare({
+ kind: 'swap',
+ amount: '1',
+ tokenIn: 'USDC',
+ tokenOut: 'SUI',
+ })
+ expect(prepared.notes.join(' ')).toMatch(/0.1% fee/)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const venue = suiModule.venues.find((v) => v.id === 'cetus')
+ expect(() => venue?.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(await dryRun(prepared.signing.transaction)).toBe(true)
+ } finally {
+ vi.unstubAllEnvs()
+ }
+ }, 60_000)
+
+ it('SpringSui builds a stake, reports the sSUI, and dry-runs clean', async () => {
+ const prepared = await prepare({ kind: 'stake', amount: '1', tokenIn: 'SUI' })
+ expect(prepared.venueId).toBe('springsui')
+ expect(prepared.receive?.symbol).toBe('sSUI')
+ expect(Number(prepared.receive?.amount)).toBeGreaterThan(0.5)
+ if (prepared.signing.family !== 'sui') throw new Error('wrong family')
+ const venue = suiModule.venues.find((v) => v.id === 'springsui')
+ expect(() => venue?.assertSafe(prepared.signing, ACCOUNT)).not.toThrow()
+ expect(await dryRun(prepared.signing.transaction)).toBe(true)
+ }, 60_000)
+
+ it("still matches SpringSui's on-chain upgrade cap and object, or the ids need a fresh look", async () => {
+ const { objects } = await suiClient().core.getObjects({
+ objectIds: ['0x4dc657b6c0fe896f4b94fee1ceac96312dde0a36b94e799caaec30deb53dcd67', SSUI_INFO],
+ include: { json: true },
+ })
+ const [cap, info] = objects as unknown as [{ json?: { package?: string } }, { type?: string }]
+ expect(cap.json?.package).toBe(SPRINGSUI_PACKAGE)
+ expect(info.type).toContain(SSUI_TYPE)
+ }, 30_000)
+
+ it("still matches NAVI's published config, or the allowlist needs a fresh look", async () => {
+ const config = await getConfig({ env: 'prod' })
+ expect(config.package).toBe(NAVI_PACKAGE)
+ }, 30_000)
+
+ it('refuses a token outside the list', async () => {
+ await expect(
+ prepare({ kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'FDUSD' })
+ ).rejects.toThrow(ChainActionRefused)
+ }, 30_000)
+
+ it('refuses over the cap', async () => {
+ await expect(
+ prepare({ kind: 'swap', amount: '501', tokenIn: 'USDC', tokenOut: 'SUI' })
+ ).rejects.toThrow(ChainActionRefused)
+ }, 30_000)
+
+ it('refuses a venue that cannot do this', async () => {
+ await expect(
+ prepare({ kind: 'swap', amount: '1', tokenIn: 'USDC', tokenOut: 'SUI', venue: 'navi' })
+ ).rejects.toThrow(/can't swap/)
+ }, 30_000)
+
+ it('says plainly when a wallet is short of the token', async () => {
+ await expect(prepare({ kind: 'supply', amount: '1', tokenIn: 'USDC' }, EMPTY)).rejects.toThrow(
+ /enough USDC/
+ )
+ }, 60_000)
+
+ it('never spoke JSON-RPC to a fullnode', () => {
+ const fullnode = requests.filter((url) => url.includes('fullnode.'))
+ expect(fullnode.length).toBeGreaterThan(0)
+ for (const url of fullnode) expect(url).toMatch(/\/sui\.rpc\./)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/venue-integration.test.ts b/apps/dapp/lib/__tests__/venue-integration.test.ts
index 7a08ab9a..5e598052 100644
--- a/apps/dapp/lib/__tests__/venue-integration.test.ts
+++ b/apps/dapp/lib/__tests__/venue-integration.test.ts
@@ -131,12 +131,15 @@ describe('DeFindex is claimed as far as it has been proven', () => {
})
describe('the integrated venues are on the right chain', () => {
- it('keeps every executing venue on Stellar', () => {
- // Execution is Stellar-only today. An EVM venue marked executable would
- // send a user to a confirm screen that cannot build anything.
+ it('keeps every executing venue on a chain that can execute', () => {
+ // An EVM venue marked executable would send a user to a confirm screen that
+ // cannot build anything: EVM chains connect but have no signing path yet.
for (const v of venues) {
if (v.integration === 'executes') {
- expect(v.family, `${v.id} claims execution but is ${v.family}`).toBe('stellar')
+ expect(
+ ['stellar', 'solana', 'sui', 'near'],
+ `${v.id} claims execution but is ${v.family}`
+ ).toContain(v.family)
}
}
})
diff --git a/apps/dapp/lib/__tests__/venue-logos.test.ts b/apps/dapp/lib/__tests__/venue-logos.test.ts
new file mode 100644
index 00000000..9da42e25
--- /dev/null
+++ b/apps/dapp/lib/__tests__/venue-logos.test.ts
@@ -0,0 +1,43 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { chainMarkFor } from '../chain-mark'
+import { venues } from '../venues'
+
+const PUBLIC = join(__dirname, '../../public/images')
+
+/** Draws the Stellar mark itself, with no file: see `VenueLogo`. */
+const DRAWN = new Set(['horizon'])
+
+describe('Apps page logos', () => {
+ it('has a logo file for every venue on every chain', () => {
+ const missing = venues
+ .filter((v) => !DRAWN.has(v.id))
+ .filter((v) => !existsSync(join(PUBLIC, 'venues', `${v.id}.webp`)))
+ .map((v) => `${v.family}/${v.id}`)
+ expect(missing).toEqual([])
+ })
+
+ it('can draw the badge of every chain a venue names', () => {
+ const chains = [...new Set(venues.flatMap((v) => v.chains))]
+ const missing = chains.filter((chain) => {
+ const mark = chainMarkFor(chain)
+ return mark.kind === 'file' && !existsSync(join(PUBLIC, 'chains', `${mark.slug}.webp`))
+ })
+ expect(missing).toEqual([])
+ })
+
+ it('draws the chains that have an official mark', () => {
+ expect(chainMarkFor('Sui')).toEqual({ kind: 'mark', chain: 'sui' })
+ expect(chainMarkFor('Solana')).toEqual({ kind: 'mark', chain: 'solana' })
+ expect(chainMarkFor('NEAR')).toEqual({ kind: 'mark', chain: 'near' })
+ expect(chainMarkFor('Arc')).toEqual({ kind: 'mark', chain: 'arc' })
+ })
+
+ it('uses a file for a chain with no drawn mark', () => {
+ expect(chainMarkFor('Ethereum')).toEqual({ kind: 'file', slug: 'ethereum' })
+ expect(chainMarkFor('dYdX Chain')).toEqual({ kind: 'file', slug: 'dydx' })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/venue-mainnet-wording.test.ts b/apps/dapp/lib/__tests__/venue-mainnet-wording.test.ts
new file mode 100644
index 00000000..24607d85
--- /dev/null
+++ b/apps/dapp/lib/__tests__/venue-mainnet-wording.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { isListedOn, notOnNetworkLabel, venueDetail, venues } from '../venues'
+
+const TESTNET = /test\s?net|test anchor/i
+
+describe('the Apps page on mainnet', () => {
+ beforeEach(() => vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', 'mainnet'))
+ afterEach(() => vi.unstubAllEnvs())
+
+ it('does not list the test anchor', () => {
+ const anchor = venues.find((v) => v.id === 'testanchor')
+ expect(anchor).toBeDefined()
+ expect(isListedOn(anchor!, 'mainnet')).toBe(false)
+ expect(isListedOn(anchor!, 'testnet')).toBe(true)
+ })
+
+ it('never mentions testnet in what any listed venue shows', () => {
+ const mentions = venues
+ .filter((v) => isListedOn(v, 'mainnet'))
+ .filter((v) => {
+ const detail = venueDetail(v, notOnNetworkLabel(v, 'mainnet'))
+ return [v.name, v.bestFor, detail].some((text) => TESTNET.test(text))
+ })
+ .map((v) => v.id)
+ expect(mentions).toEqual([])
+ })
+})
+
+describe('venueDetail', () => {
+ const venue = { bestFor: 'Short', capability: 'What it does here' }
+
+ it('gives the capability where the venue works', () => {
+ expect(venueDetail(venue, undefined)).toBe('What it does here')
+ expect(venueDetail({ bestFor: 'Short' }, undefined)).toBe('Short')
+ })
+
+ it('falls back to the summary where it does not', () => {
+ expect(venueDetail(venue, 'Not on mainnet yet')).toBe('Short')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/wallet-connect.test.ts b/apps/dapp/lib/__tests__/wallet-connect.test.ts
new file mode 100644
index 00000000..cf6aa2f5
--- /dev/null
+++ b/apps/dapp/lib/__tests__/wallet-connect.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { isWalletAddress } from '../wallet/address'
+import { connectAddress, pathUnderChain, screenAfterConnect } from '../wallet/chain-path'
+import { isUserRejection, orderWallets } from '../wallet/connector'
+import { evmWalletOptions } from '../wallet/evm-options'
+import { createWalletStore } from '../wallet/wallet-store'
+
+describe('isWalletAddress', () => {
+ it.each([
+ ['Stellar', 'GBD7QFQVR4QWNEJSHP4VN7RAAUKXTMZ4EJ4EBMCR7CP3HMF7RXEASTD7'],
+ ['EVM', '0x52908400098527886E0F7030069857D2E4169EE7'],
+ ['Sui', `0x${'ab'.repeat(32)}`],
+ ['Solana', 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg'],
+ ['NEAR named', 'alice.near'],
+ ['NEAR nested', 'app.alice-1.near'],
+ ['NEAR implicit', 'ab'.repeat(32)],
+ ])('accepts a %s address', (_, address) => {
+ expect(isWalletAddress(address)).toBe(true)
+ })
+
+ it.each([
+ 'not-an-address',
+ '',
+ 'alice',
+ '0x1234',
+ 'alice..near',
+ 'ALICE.near',
+ 'GBD7,GBD7',
+ 'alice.near,bob.near',
+ ])('refuses %j', (value) => {
+ expect(isWalletAddress(value)).toBe(false)
+ })
+})
+
+describe('pathUnderChain', () => {
+ it('keeps the screen', () => {
+ expect(pathUnderChain('/stellar/history', 'near')).toBe('/near/history')
+ expect(pathUnderChain('/near/intents/abc', 'sui')).toBe('/sui/intents/abc')
+ })
+
+ it('opens intents from a bare chain address', () => {
+ expect(pathUnderChain('/stellar', 'solana')).toBe('/solana/intents')
+ })
+})
+
+describe('orderWallets', () => {
+ it('puts ready wallets first and keeps order within each group', () => {
+ const wallet = (id: string, detected: boolean) => ({ id, name: id, detected })
+ const ordered = orderWallets([
+ wallet('a', false),
+ wallet('b', true),
+ wallet('c', false),
+ wallet('d', true),
+ ])
+ expect(ordered.map((o) => o.id)).toEqual(['b', 'd', 'a', 'c'])
+ })
+})
+
+describe('isUserRejection', () => {
+ it('treats a decline as ordinary, and a fault as a fault', () => {
+ expect(isUserRejection('User rejected the request.')).toBe(true)
+ expect(isUserRejection('The user declined')).toBe(true)
+ expect(isUserRejection('Popup closed')).toBe(true)
+ expect(isUserRejection('Internal JSON-RPC error')).toBe(false)
+ })
+})
+
+describe('evmWalletOptions', () => {
+ const generic = { id: 'injected', name: 'Browser Wallet', type: 'injected' }
+ const metamask = { id: 'io.metamask', name: 'MetaMask', type: 'injected', icon: 'mm.svg' }
+
+ it('hides the generic injected wallet when a named one is present', () => {
+ const options = evmWalletOptions([generic, metamask])
+ expect(options.filter((o) => o.detected).map((o) => o.id)).toEqual(['io.metamask'])
+ })
+
+ it('keeps the generic injected wallet when nothing named turned up', () => {
+ const options = evmWalletOptions([generic])
+ expect(options.filter((o) => o.detected).map((o) => o.id)).toEqual(['injected'])
+ })
+
+ it('keeps generic beside WalletConnect, which is not a browser wallet', () => {
+ const wc = { id: 'walletConnect', name: 'WalletConnect', type: 'walletConnect' }
+ const options = evmWalletOptions([generic, wc])
+ expect(options.filter((o) => o.detected).map((o) => o.id)).toEqual([
+ 'injected',
+ 'walletConnect',
+ ])
+ })
+
+ it('offers an install link only for wallets not detected', () => {
+ const options = evmWalletOptions([metamask])
+ expect(options.find((o) => o.name === 'MetaMask')?.detected).toBe(true)
+ const rabby = options.find((o) => o.name === 'Rabby Wallet')
+ expect(rabby?.detected).toBe(false)
+ expect(rabby?.installUrl).toBe('https://rabby.io')
+ })
+})
+
+describe('createWalletStore', () => {
+ function memory(initial: Record = {}) {
+ const data = new Map(Object.entries(initial))
+ return {
+ getItem: (k: string) => data.get(k) ?? null,
+ setItem: (k: string, v: string) => void data.set(k, v),
+ removeItem: (k: string) => void data.delete(k),
+ }
+ }
+
+ it('is restoring only when a wallet is remembered', () => {
+ expect(createWalletStore('k', () => memory()).get().isRestoring).toBe(false)
+ expect(createWalletStore('k', () => memory({ k: 'Phantom' })).get().isRestoring).toBe(true)
+ })
+
+ it('stays undecided on the server, so nothing renders a connect page there', () => {
+ expect(createWalletStore('k', () => memory()).getServer().isRestoring).toBe(true)
+ })
+
+ it('notifies subscribers until they leave', () => {
+ const store = createWalletStore('k', () => memory())
+ const listener = vi.fn()
+ const leave = store.subscribe(listener)
+ store.set({ address: 'a' })
+ expect(store.get().address).toBe('a')
+ expect(listener).toHaveBeenCalledTimes(1)
+ leave()
+ store.set({ address: 'b' })
+ expect(listener).toHaveBeenCalledTimes(1)
+ })
+
+ it('remembers and forgets a wallet', () => {
+ const storage = memory()
+ const store = createWalletStore('k', () => storage)
+ store.remember('Phantom')
+ expect(store.remembered()).toBe('Phantom')
+ store.remember(undefined)
+ expect(store.remembered()).toBeUndefined()
+ })
+
+ it('survives storage that throws, as private browsing does', () => {
+ const blocked = () => {
+ throw new Error('blocked')
+ }
+ const store = createWalletStore('k', () => ({
+ getItem: blocked,
+ setItem: blocked,
+ removeItem: blocked,
+ }))
+ expect(store.remembered()).toBeUndefined()
+ expect(() => store.remember('Phantom')).not.toThrow()
+ })
+})
+
+describe('connectAddress', () => {
+ it('is the bare address for the default screen', () => {
+ expect(connectAddress('/stellar/intents', '')).toBe('/')
+ expect(connectAddress('/stellar', '')).toBe('/')
+ })
+
+ it('carries any other screen through, with its query', () => {
+ expect(connectAddress('/near/agents', '')).toBe('/?next=%2Fagents')
+ expect(connectAddress('/near/apps', '?a=1')).toBe('/?next=%2Fapps%3Fa%3D1')
+ })
+})
+
+describe('screenAfterConnect', () => {
+ it('opens intents by default', () => {
+ expect(screenAfterConnect(null)).toBe('/intents')
+ expect(screenAfterConnect('/')).toBe('/intents')
+ })
+
+ it('keeps a screen and its query', () => {
+ expect(screenAfterConnect('/agents')).toBe('/agents')
+ expect(screenAfterConnect('/apps?a=1')).toBe('/apps?a=1')
+ })
+
+ it.each(['https://evil.example', '//evil.example', '/\\evil.example', 'agents', '/../admin'])(
+ 'refuses %j',
+ (value) => {
+ expect(screenAfterConnect(value)).toBe('/intents')
+ }
+ )
+})
diff --git a/apps/dapp/lib/__tests__/weave-balance-route.test.ts b/apps/dapp/lib/__tests__/weave-balance-route.test.ts
new file mode 100644
index 00000000..8c44bae8
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-balance-route.test.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+ usdcBalance: vi.fn(),
+ isAccount: vi.fn(),
+ guard: { ok: true, email: 'wallet:a' } as unknown,
+}))
+
+vi.mock('../weave/guard', () => ({ guardCashout: () => state.guard }))
+vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined }))
+vi.mock('../server/report', () => ({ reportError: () => undefined }))
+vi.mock('../weave/deposit', () => ({
+ depositChain: () => ({
+ usdcDecimals: 6,
+ isAccount: state.isAccount,
+ usdcBalance: state.usdcBalance,
+ }),
+}))
+
+import { GET } from '../../app/api/cashout/balance/route'
+
+function get(query: string): Request {
+ return new Request(`http://localhost/api/cashout/balance?${query}`)
+}
+
+beforeEach(() => {
+ state.usdcBalance.mockReset()
+ state.isAccount.mockReset().mockReturnValue(true)
+})
+
+describe('GET /api/cashout/balance', () => {
+ it('answers in display units, exactly', async () => {
+ state.usdcBalance.mockResolvedValue(BigInt(12345678))
+ const res = await GET(get('chain=sui&account=0x1'))
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ balance: '12.345678' })
+ })
+
+ it('answers zero and whole amounts plainly', async () => {
+ state.usdcBalance.mockResolvedValueOnce(BigInt(0))
+ expect(await (await GET(get('chain=sui&account=0x1'))).json()).toEqual({ balance: '0' })
+ state.usdcBalance.mockResolvedValueOnce(BigInt(3000000))
+ expect(await (await GET(get('chain=sui&account=0x1'))).json()).toEqual({ balance: '3' })
+ })
+
+ it('refuses an unknown chain and a bad account', async () => {
+ expect((await GET(get('chain=monad&account=0x1'))).status).toBe(400)
+ state.isAccount.mockReturnValue(false)
+ expect((await GET(get('chain=sui&account=nope'))).status).toBe(400)
+ expect(state.usdcBalance).not.toHaveBeenCalled()
+ })
+
+ it('says it could not read rather than answering zero', async () => {
+ state.usdcBalance.mockRejectedValue(new Error('rpc down'))
+ const res = await GET(get('chain=sui&account=0x1'))
+ expect(res.status).toBe(503)
+ expect((await res.json()).balance).toBeUndefined()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-bank-logos.test.ts b/apps/dapp/lib/__tests__/weave-bank-logos.test.ts
new file mode 100644
index 00000000..263b0e26
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-bank-logos.test.ts
@@ -0,0 +1,54 @@
+import { existsSync, readdirSync, statSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { NIGERIAN_BANKS } from '../naira/banks'
+import { WEAVE_BANK_LOGOS } from '../weave/bank-logos'
+import { withBankLogos } from '../weave/cashout-view'
+
+const DIR = join(process.cwd(), 'public', 'bank-logos', 'weave')
+
+describe('bank logos', () => {
+ const entries = Object.entries(WEAVE_BANK_LOGOS).flatMap(([currency, banks]) =>
+ Object.entries(banks).map(([name, slug]) => ({ currency, name, slug }))
+ )
+
+ it('has a list for every country a payout can go to', () => {
+ expect(Object.keys(WEAVE_BANK_LOGOS).sort()).toEqual(['KES', 'NGN', 'TZS', 'UGX'])
+ })
+
+ it('has a file for every entry', () => {
+ const missing = entries.filter(({ slug }) => !existsSync(join(DIR, `${slug}.webp`)))
+ expect(missing).toEqual([])
+ })
+
+ it('keeps no file that nothing uses', () => {
+ const used = new Set(entries.map(({ slug }) => `${slug}.webp`))
+ expect(readdirSync(DIR).filter((file) => !used.has(file))).toEqual([])
+ })
+
+ it('keeps each file small, since a picker loads many at once', () => {
+ const large = readdirSync(DIR).filter((file) => statSync(join(DIR, file)).size > 30_000)
+ expect(large).toEqual([])
+ })
+
+ it.each([
+ ['KES', 'M-PESA'],
+ ['KES', 'Equity Bank'],
+ ['UGX', 'MTN Mobile Money'],
+ ['UGX', 'Airtel Money'],
+ ['TZS', 'CRDB Bank PLC'],
+ ['TZS', 'Tigo Pesa'],
+ ['NGN', 'OPay'],
+ ['NGN', 'PalmPay'],
+ ['NGN', 'Access Bank'],
+ ])('shows a logo for %s %s, which many people will pick', (currency, name) => {
+ const [bank] = withBankLogos(
+ [{ code: 'X', name, type: 'bank' }],
+ currency === 'NGN' ? NIGERIAN_BANKS : [],
+ currency
+ )
+ expect(bank?.logo).toBeDefined()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts b/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts
new file mode 100644
index 00000000..d413eb72
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts
@@ -0,0 +1,394 @@
+import { createHmac } from 'node:crypto'
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+ mainnet: true,
+ db: true,
+ weave: {} as Record,
+ repo: {} as Record,
+ deposit: {} as Record,
+ report: [] as unknown[][],
+}))
+
+vi.mock('@intent/config', async (original) => ({
+ ...(await original()),
+ isMainnet: () => state.mainnet,
+}))
+vi.mock('../weave/client', async (original) => ({
+ ...(await original()),
+ createWeaveClient: () => state.weave,
+}))
+vi.mock('../weave/orders', async (original) => ({
+ ...(await original()),
+ getWeaveOrderRepo: async () => state.repo,
+}))
+vi.mock('../weave/deposit', () => ({ depositChain: () => state.deposit }))
+vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined }))
+vi.mock('../server/db', async (original) => ({
+ ...(await original()),
+ databaseConfigured: () => state.db,
+}))
+vi.mock('../server/report', () => ({
+ reportError: (...args: unknown[]) => state.report.push(args),
+}))
+
+import { GET as institutions } from '../../app/api/cashout/institutions/route'
+import { GET as options } from '../../app/api/cashout/options/route'
+import { GET as one } from '../../app/api/cashout/orders/[id]/route'
+import { GET as list } from '../../app/api/cashout/orders/route'
+import { GET as quote } from '../../app/api/cashout/quote/route'
+import { POST as verify } from '../../app/api/cashout/verify/route'
+import { POST as webhook } from '../../app/api/cashout/webhook/route'
+import { WALLET_HEADER } from '../wallet-header'
+
+const ID = '3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456'
+const DIGITS = '254700001234'
+const fn = (value: unknown): ReturnType => value as ReturnType
+
+function get(path: string): Request {
+ return new Request(`http://localhost/api/cashout/${path}`, {
+ headers: { [WALLET_HEADER]: 'alice.near' },
+ })
+}
+
+function row(over: Record = {}): Record {
+ return {
+ id: ID,
+ chain: 'near',
+ email: 'wallet:alice.near',
+ account: 'alice.near',
+ providerOrder: 'ord_1',
+ idempotencyKey: 'key',
+ currency: 'KES',
+ institutionCode: 'MPESA',
+ institutionName: 'M-Pesa',
+ accountIdentifier: DIGITS,
+ accountName: 'JANE ADEYEMI',
+ depositAddress: 'weave.near',
+ depositMemo: undefined,
+ amount: '25.5',
+ estimatedDest: '3200.5',
+ expiresAt: undefined,
+ status: 'waiting',
+ providerStatus: 'awaiting_deposit',
+ depositHash: undefined,
+ createdAt: '2026-11-04T11:00:00.000Z',
+ ...over,
+ }
+}
+
+beforeEach(() => {
+ state.mainnet = true
+ state.db = true
+ state.report = []
+ state.deposit = { usdcDecimals: 6 }
+ state.weave = {
+ configured: true,
+ institutions: vi.fn(async () => ({
+ ok: true,
+ value: [{ name: 'M-Pesa', code: 'MPESA', type: 'mobile_money' }],
+ })),
+ verifyAccount: vi.fn(async () => ({ ok: true, value: { accountName: 'JANE ADEYEMI' } })),
+ quote: vi.fn(async () => ({
+ ok: true,
+ value: { destAmount: 3200.5, netUsd: 24.5, seconds: 90 },
+ })),
+ getOrder: vi.fn(async () => ({ ok: true, value: { id: 'ord_1', status: 'awaiting_deposit' } })),
+ }
+ state.repo = {
+ findHolder: vi.fn(async () => undefined),
+ getForEmail: vi.fn(async () => row()),
+ getByProviderOrder: vi.fn(async () => row()),
+ listForEmail: vi.fn(async () => [row()]),
+ setStatus: vi.fn(async () => undefined),
+ }
+ vi.stubEnv('WEAVE_CASHOUT_CURRENCIES', 'NGN,KES')
+ vi.stubEnv('WEAVE_WEBHOOK_SECRET', 'whsec')
+})
+
+describe('GET options', () => {
+ it('lists what this chain can cash out to, with names', async () => {
+ const body = await (await options(get('options?chain=near'))).json()
+ expect(body.currencies.map((c: { code: string }) => c.code)).toEqual(['NGN', 'KES'])
+ expect(body.currencies[1]).toMatchObject({ country: 'Kenya', symbol: 'KSh' })
+ })
+
+ it('leaves naira off Stellar', async () => {
+ const body = await (await options(get('options?chain=stellar'))).json()
+ expect(body.currencies.map((c: { code: string }) => c.code)).toEqual(['KES'])
+ })
+
+ it('refuses a chain it does not know', async () => {
+ expect((await options(get('options?chain=ethereum'))).status).toBe(400)
+ expect((await options(get('options'))).status).toBe(400)
+ })
+
+ it('refuses Stellar off mainnet', async () => {
+ state.mainnet = false
+ expect((await options(get('options?chain=stellar'))).status).toBe(403)
+ })
+
+ it('needs a requester', async () => {
+ expect(
+ (await options(new Request('http://localhost/api/cashout/options?chain=near'))).status
+ ).toBe(401)
+ })
+})
+
+describe('GET institutions', () => {
+ it('lists a live currency', async () => {
+ const body = await (await institutions(get('institutions?currency=KES'))).json()
+ expect(body.institutions).toEqual([{ name: 'M-Pesa', code: 'MPESA', type: 'mobile_money' }])
+ })
+
+ it('refuses a currency that is not live, asking Weave nothing', async () => {
+ expect((await institutions(get('institutions?currency=UGX'))).status).toBe(400)
+ expect((await institutions(get('institutions'))).status).toBe(400)
+ expect(fn(state.weave['institutions'])).not.toHaveBeenCalled()
+ })
+
+ it('passes on a failure in our words', async () => {
+ fn(state.weave['institutions']).mockResolvedValue({
+ ok: false,
+ code: 'unavailable',
+ message: 'Busy.',
+ })
+ const res = await institutions(get('institutions?currency=KES'))
+ expect(res.status).toBe(503)
+ expect((await res.json()).message).toBe('Busy.')
+ })
+})
+
+describe('POST verify', () => {
+ const post = (body: unknown): Request =>
+ new Request('http://localhost/api/cashout/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', [WALLET_HEADER]: 'alice.near' },
+ body: JSON.stringify(body),
+ })
+ const good = { currency: 'KES', institution: 'MPESA', accountIdentifier: DIGITS }
+
+ it('returns the name Weave gives', async () => {
+ const res = await verify(post(good))
+ expect(await res.json()).toEqual({ accountName: 'JANE ADEYEMI' })
+ expect(fn(state.weave['verifyAccount'])).toHaveBeenCalledWith({
+ institution: 'MPESA',
+ accountIdentifier: DIGITS,
+ currency: 'KES',
+ })
+ })
+
+ it("uses this requester's earlier order to the same account", async () => {
+ fn(state.repo['findHolder']).mockResolvedValue('JANE BEFORE')
+ const res = await verify(post(good))
+ expect(await res.json()).toEqual({ accountName: 'JANE BEFORE' })
+ expect(fn(state.repo['findHolder'])).toHaveBeenCalledWith(
+ 'wallet:alice.near',
+ 'KES',
+ 'MPESA',
+ DIGITS
+ )
+ expect(fn(state.weave['verifyAccount'])).not.toHaveBeenCalled()
+ })
+
+ it('refuses bad input, asking Weave nothing', async () => {
+ expect((await verify(post({ ...good, currency: 'UGX' }))).status).toBe(400)
+ expect((await verify(post({ ...good, accountIdentifier: '1;2' }))).status).toBe(400)
+ expect(fn(state.weave['verifyAccount'])).not.toHaveBeenCalled()
+ })
+
+ it('answers a refusal as a refusal', async () => {
+ fn(state.weave['verifyAccount']).mockResolvedValue({
+ ok: false,
+ code: 'rejected',
+ message: 'That account could not be verified.',
+ })
+ expect((await verify(post(good))).status).toBe(422)
+ })
+})
+
+describe('GET quote', () => {
+ it('gives the dest amount, the cost from netUsd and the time', async () => {
+ const res = await quote(get('quote?chain=near¤cy=KES&amount=25'))
+ expect(await res.json()).toEqual({
+ destAmount: 3200.5,
+ costPercent: 2,
+ seconds: 90,
+ })
+ })
+
+ it('shows no negative cost', async () => {
+ fn(state.weave['quote']).mockResolvedValue({
+ ok: true,
+ value: { destAmount: 1, netUsd: 26, seconds: 1 },
+ })
+ const res = await quote(get('quote?chain=near¤cy=KES&amount=25'))
+ expect((await res.json()).costPercent).toBe(0)
+ })
+
+ it.each([
+ ['chain=ethereum¤cy=KES&amount=25'],
+ ['chain=near¤cy=UGX&amount=25'],
+ ['chain=stellar¤cy=NGN&amount=25'],
+ ['chain=near¤cy=KES&amount=1.1234567'],
+ ['chain=near¤cy=KES&amount=0'],
+ ['chain=near¤cy=KES'],
+ ])('refuses %s', async (query) => {
+ expect((await quote(get(`quote?${query}`))).status).toBe(400)
+ expect(fn(state.weave['quote'])).not.toHaveBeenCalled()
+ })
+})
+
+describe('GET orders', () => {
+ it("lists only this requester's orders, masked", async () => {
+ const res = await list(get('orders'))
+ const text = JSON.stringify(await res.json())
+ expect(fn(state.repo['listForEmail'])).toHaveBeenCalledWith('wallet:alice.near', 50)
+ expect(text).not.toContain(DIGITS)
+ expect(text).toContain('••••1234')
+ })
+
+ it('needs a requester', async () => {
+ expect((await list(new Request('http://localhost/api/cashout/orders'))).status).toBe(401)
+ })
+
+ it('answers an empty list with no database', async () => {
+ state.db = false
+ expect(await (await list(get('orders'))).json()).toEqual({ orders: [] })
+ })
+
+ it('answers an empty list when the read fails', async () => {
+ fn(state.repo['listForEmail']).mockRejectedValue(new Error('down'))
+ expect(await (await list(get('orders'))).json()).toEqual({ orders: [] })
+ })
+})
+
+describe('GET one order', () => {
+ const context = { params: Promise.resolve({ id: ID }) }
+
+ it('syncs from Weave and answers the moved status', async () => {
+ fn(state.weave['getOrder']).mockResolvedValue({
+ ok: true,
+ value: { id: 'ord_1', status: 'processing' },
+ })
+ const res = await one(get(`orders/${ID}`), context)
+ expect((await res.json()).order.status).toBe('processing')
+ expect(fn(state.repo['setStatus'])).toHaveBeenCalledWith('ord_1', 'processing', 'processing')
+ })
+
+ it('does not ask Weave about a final order', async () => {
+ fn(state.repo['getForEmail']).mockResolvedValue(
+ row({ status: 'paid', providerStatus: 'completed' })
+ )
+ const res = await one(get(`orders/${ID}`), context)
+ expect((await res.json()).order.status).toBe('paid')
+ expect(fn(state.weave['getOrder'])).not.toHaveBeenCalled()
+ })
+
+ it('falls back to the stored row when Weave cannot be reached', async () => {
+ fn(state.weave['getOrder']).mockResolvedValue({ ok: false, code: 'unavailable', message: 'x' })
+ const res = await one(get(`orders/${ID}`), context)
+ expect(res.status).toBe(200)
+ expect((await res.json()).order.status).toBe('waiting')
+ })
+
+ it("answers not found for somebody else's order", async () => {
+ fn(state.repo['getForEmail']).mockResolvedValue(undefined)
+ expect((await one(get(`orders/${ID}`), context)).status).toBe(404)
+ expect(fn(state.repo['getForEmail'])).toHaveBeenCalledWith(ID, 'wallet:alice.near')
+ })
+
+ it('never returns the digits', async () => {
+ const text = JSON.stringify(await (await one(get(`orders/${ID}`), context)).json())
+ expect(text).not.toContain(DIGITS)
+ })
+})
+
+describe('POST webhook', () => {
+ const event = (over: Record = {}, data: Record = {}): string =>
+ JSON.stringify({
+ event: 'order.completed',
+ data: { id: 'ord_1', reference: 'key', status: 'completed', ...data },
+ ...over,
+ })
+
+ function signed(body: string, secret = 'whsec', ts = Math.floor(Date.now() / 1000)): Request {
+ const signature = createHmac('sha256', secret).update(`${ts}.`).update(body).digest('hex')
+ return new Request('http://localhost/api/cashout/webhook', {
+ method: 'POST',
+ headers: { 'X-Weave-Signature': `sha256=${signature}`, 'X-Weave-Timestamp': String(ts) },
+ body,
+ })
+ }
+
+ it('moves the order', async () => {
+ const res = await webhook(signed(event()))
+ expect(res.status).toBe(200)
+ expect(fn(state.repo['setStatus'])).toHaveBeenCalledWith('ord_1', 'paid', 'completed')
+ })
+
+ it('falls back to the event name when the body has no status', async () => {
+ await webhook(signed(event({ event: 'order.failed' }, { status: undefined })))
+ expect(fn(state.repo['setStatus'])).toHaveBeenCalledWith('ord_1', 'failed', 'failed')
+ })
+
+ it('verifies the bytes as they arrived', async () => {
+ const spaced = `{ "event": "order.completed", "data": { "id": "ord_1", "status": "completed" } }`
+ expect((await webhook(signed(spaced))).status).toBe(200)
+ expect(fn(state.repo['setStatus'])).toHaveBeenCalled()
+ })
+
+ it('refuses a bad signature before reading anything', async () => {
+ const res = await webhook(signed(event(), 'other'))
+ expect(res.status).toBe(401)
+ expect(fn(state.repo['getByProviderOrder'])).not.toHaveBeenCalled()
+ })
+
+ it('refuses a stale timestamp', async () => {
+ const res = await webhook(signed(event(), 'whsec', Math.floor(Date.now() / 1000) - 301))
+ expect(res.status).toBe(401)
+ })
+
+ it('fails closed when the secret is unset', async () => {
+ vi.stubEnv('WEAVE_WEBHOOK_SECRET', '')
+ expect((await webhook(signed(event(), ''))).status).toBe(401)
+ expect(fn(state.repo['getByProviderOrder'])).not.toHaveBeenCalled()
+ })
+
+ it('answers 200 for an order it does not know', async () => {
+ fn(state.repo['getByProviderOrder']).mockResolvedValue(undefined)
+ expect((await webhook(signed(event()))).status).toBe(200)
+ expect(fn(state.repo['setStatus'])).not.toHaveBeenCalled()
+ })
+
+ it('ignores a replay', async () => {
+ fn(state.repo['getByProviderOrder']).mockResolvedValue(
+ row({ status: 'paid', providerStatus: 'completed' })
+ )
+ expect((await webhook(signed(event()))).status).toBe(200)
+ expect(fn(state.repo['setStatus'])).not.toHaveBeenCalled()
+ })
+
+ it('does not let a late event move a final order', async () => {
+ fn(state.repo['getByProviderOrder']).mockResolvedValue(
+ row({ status: 'paid', providerStatus: 'completed' })
+ )
+ await webhook(signed(event({ event: 'order.failed' }, { status: 'failed' })))
+ expect(fn(state.repo['setStatus'])).not.toHaveBeenCalled()
+ })
+
+ it('answers 200 for a signed event with no order id', async () => {
+ expect((await webhook(signed(JSON.stringify({ event: 'ping' })))).status).toBe(200)
+ })
+
+ it('answers 400 for a signed body that is not JSON', async () => {
+ expect((await webhook(signed('nope'))).status).toBe(400)
+ })
+
+ it('answers non-2xx when the record cannot be written, so Weave retries', async () => {
+ fn(state.repo['setStatus']).mockRejectedValue(new Error('down'))
+ expect((await webhook(signed(event()))).status).toBe(500)
+ expect(state.report).toHaveLength(1)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-cashout-view.test.ts b/apps/dapp/lib/__tests__/weave-cashout-view.test.ts
new file mode 100644
index 00000000..e7e34f88
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-cashout-view.test.ts
@@ -0,0 +1,389 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ accountHint,
+ accountMismatch,
+ afterSubmit,
+ afterWallet,
+ canSign,
+ CASHOUT_STATUSES,
+ cleanIdentifier,
+ depositConfirmed,
+ depositExplorerUrl,
+ cashoutChoices,
+ explorerName,
+ formatPayout,
+ formatUsdc,
+ isLocked,
+ isRealHolderName,
+ isValidAmount,
+ nextAttempt,
+ orderPhase,
+ shouldPoll,
+ statusText,
+ unsupportedCurrencyReply,
+ unsupportedTokenReply,
+ withBankLogos,
+ type CashoutOrderView,
+} from '../weave/cashout-view'
+
+const order = (over: Partial = {}): CashoutOrderView => ({
+ id: 'o1',
+ chain: 'near',
+ status: 'waiting',
+ currency: 'KES',
+ amount: '25',
+ estimatedDest: '3200',
+ institutionName: 'M-Pesa',
+ accountName: 'Jane',
+ accountMask: '***678',
+ depositAddress: 'deposit',
+ ...over,
+})
+
+describe('formatting', () => {
+ it('groups USDC without changing its digits', () => {
+ expect(formatUsdc('1000.123456')).toBe('1,000.123456')
+ expect(formatUsdc('25')).toBe('25')
+ expect(formatUsdc('1250000')).toBe('1,250,000')
+ })
+
+ it('writes naira the way the naira form does', () => {
+ expect(formatPayout('4121.25', 'NGN')).toBe('₦4,121.25')
+ expect(formatPayout(4121, 'NGN')).toBe('₦4,121.00')
+ })
+
+ it('writes the other currencies with their own marks', () => {
+ expect(formatPayout('3200.456', 'KES')).toBe('KSh 3,200.46')
+ expect(formatPayout(100, 'UGX')).toBe('USh 100.00')
+ expect(formatPayout(100, 'TZS')).toBe('TSh 100.00')
+ expect(formatPayout(100, 'XYZ')).toBe('100.00 XYZ')
+ })
+
+ it('does not take a bare OK for a holder’s name', () => {
+ expect(isRealHolderName('OK')).toBe(false)
+ expect(isRealHolderName(' ok ')).toBe(false)
+ expect(isRealHolderName('JANE ADEYEMI')).toBe(true)
+ })
+
+ it('names each chain’s explorer', () => {
+ expect(explorerName('stellar')).toBe('Stellar Expert')
+ expect(explorerName('sui')).toBe('Suiscan')
+ })
+})
+
+describe('cashoutChoices', () => {
+ const kes = { code: 'KES', name: 'Kenyan shilling', symbol: 'KSh', country: 'Kenya' }
+ const ngn = { code: 'NGN', name: 'Nigerian naira', symbol: '₦', country: 'Nigeria' }
+
+ it('takes what Weave offers on a chain', () => {
+ expect(cashoutChoices('sui', [ngn, kes])).toEqual([ngn, kes])
+ expect(cashoutChoices('sui', [])).toEqual([])
+ })
+
+ it('offers naira first on stellar, which Linq pays even with Weave off', () => {
+ expect(cashoutChoices('stellar', [kes]).map((c) => c.code)).toEqual(['NGN', 'KES'])
+ expect(cashoutChoices('stellar', []).map((c) => c.code)).toEqual(['NGN'])
+ })
+})
+
+describe('withBankLogos', () => {
+ const list = [
+ { code: 'ZEN', name: 'Zenith Bank', type: 'bank' as const },
+ { code: 'ABNGNGLA', name: 'ACCESS BANK', type: 'bank' as const },
+ { code: 'NEW', name: 'New Bank', type: 'bank' as const },
+ ]
+ const known = [
+ { code: '044', name: 'Access Bank', logo: '/access.png' },
+ { code: '057', name: 'Zenith Bank' },
+ ]
+
+ it('sorts A to Z and keeps the provider’s codes', () => {
+ expect(withBankLogos(list, known, 'XXX').map((b) => b.code)).toEqual(['ABNGNGLA', 'NEW', 'ZEN'])
+ })
+
+ it('takes the logo from the same bank, however its name is written', () => {
+ const banks = withBankLogos(list, known, 'XXX')
+ expect(banks[0]).toEqual({ code: 'ABNGNGLA', name: 'ACCESS BANK', logo: '/access.png' })
+ })
+
+ it('leaves a bank with no known logo without one', () => {
+ const banks = withBankLogos(list, known, 'XXX')
+ expect(banks[1]).toEqual({ code: 'NEW', name: 'New Bank' })
+ expect(banks[2]).toEqual({ code: 'ZEN', name: 'Zenith Bank' })
+ })
+
+ it('takes the logo held for that country', () => {
+ const [bank] = withBankLogos([{ code: 'MP', name: 'M-PESA', type: 'mobile_money' }], [], 'KES')
+ expect(bank?.logo).toMatch(/^\/bank-logos\/weave\/.+\.webp$/)
+ const [elsewhere] = withBankLogos(
+ [{ code: 'MP', name: 'M-PESA', type: 'mobile_money' }],
+ [],
+ 'UGX'
+ )
+ expect(elsewhere?.logo).toBeUndefined()
+ })
+
+ it('keeps the naira form’s own logo for a bank it already has', () => {
+ const [bank] = withBankLogos(
+ [{ code: 'KU', name: 'Kuda Microfinance Bank', type: 'bank' }],
+ [{ name: 'Kuda Microfinance Bank', logo: '/bank-logos/090267.png' }],
+ 'NGN'
+ )
+ expect(bank?.logo).toBe('/bank-logos/090267.png')
+ })
+})
+
+describe('isValidAmount', () => {
+ it.each(['25', '0.5', '25.123456'])('takes %s', (a) => expect(isValidAmount(a)).toBe(true))
+ it.each(['', '0', '0.00', '.', '1.', '1.1234567', 'abc', '-1'])('refuses %j', (a) =>
+ expect(isValidAmount(a)).toBe(false)
+ )
+})
+
+describe('status', () => {
+ it('has a sentence for each status', () => {
+ for (const status of CASHOUT_STATUSES) expect(statusText(status).length).toBeGreaterThan(10)
+ })
+
+ it('tells a failed payout where the money goes', () => {
+ expect(statusText('failed')).toMatch(/due back to your connected wallet/)
+ })
+
+ it.each(['waiting', 'processing'] as const)('keeps polling while %s', (status) => {
+ expect(shouldPoll(status)).toBe(true)
+ })
+
+ it.each(['paid', 'failed', 'expired', 'refunded'] as const)('stops polling once %s', (status) => {
+ expect(shouldPoll(status)).toBe(false)
+ })
+})
+
+describe('accountHint', () => {
+ it('asks a bank for an account number', () => {
+ expect(accountHint('bank', 'KES')).toMatchObject({
+ label: 'Account number',
+ inputMode: 'numeric',
+ })
+ })
+
+ it('asks mobile money for a phone number with the right country code', () => {
+ expect(accountHint('mobile_money', 'KES')).toMatchObject({
+ label: 'Phone number',
+ inputMode: 'tel',
+ placeholder: '254712345678',
+ })
+ expect(accountHint('mobile_money', 'UGX').placeholder).toMatch(/^256/)
+ expect(accountHint('mobile_money', 'TZS').placeholder).toMatch(/^255/)
+ })
+
+ it('asks generally until an institution is picked', () => {
+ expect(accountHint(undefined, 'KES').label).toBe('Account or phone number')
+ })
+
+ it('removes what people type between digits', () => {
+ expect(cleanIdentifier(' 0712 345-678 ')).toBe('0712345678')
+ expect(cleanIdentifier('+254 712 345678')).toBe('+254712345678')
+ })
+})
+
+describe('afterWallet', () => {
+ it('moves a signature on to submit', () => {
+ const signed = { family: 'solana', signedTransaction: 'x' } as const
+ expect(afterWallet({ ok: true, signed }, 'solana')).toEqual({ next: 'submit', signed })
+ })
+
+ it.each(['solana', 'sui', 'stellar'] as const)(
+ 'returns to review when declined on %s, since nothing can have gone out',
+ (family) => {
+ expect(afterWallet({ ok: false, reason: 'rejected' }, family)).toEqual({ next: 'review' })
+ }
+ )
+
+ it.each(['solana', 'sui', 'stellar'] as const)(
+ 'calls a wallet error on %s a failure, since only this app sends those',
+ (family) => {
+ expect(afterWallet({ ok: false, reason: 'wallet_error', detail: 'no' }, family)).toEqual({
+ next: 'failed',
+ error: 'no',
+ })
+ }
+ )
+
+ it.each(['near', 'evm'] as const)(
+ 'asks for a check after a wallet error on %s, since that wallet sends for itself',
+ (family) => {
+ const result = afterWallet({ ok: false, reason: 'wallet_error', detail: 'no' }, family)
+ expect(result).toEqual({ next: 'check' })
+ }
+ )
+
+ it.each(['near', 'evm'] as const)(
+ 'asks for a check after a decline on %s, since a dropped connection can read as one',
+ (family) => {
+ expect(afterWallet({ ok: false, reason: 'rejected' }, family)).toEqual({ next: 'check' })
+ }
+ )
+})
+
+describe('accountMismatch', () => {
+ it('passes the wallet the order was made for', () => {
+ expect(accountMismatch('alice.near', 'alice.near')).toBeUndefined()
+ })
+
+ it('reads EVM addresses without regard to case', () => {
+ expect(accountMismatch('0xABCDEF1234', '0xabcdef1234')).toBeUndefined()
+ })
+
+ it('keeps case on chains where it is part of the address', () => {
+ expect(accountMismatch('GABCDEF123456', 'gabcdef123456')).toMatch(/Switch back/)
+ })
+
+ it('asks for the other wallet by a short name when they differ', () => {
+ const message = accountMismatch('0x1111111111222222', '0x3333333333444444')
+ expect(message).toMatch(/Switch back/)
+ expect(message).toMatch(/0x3333…4444/)
+ })
+
+ it('asks the same when no wallet is connected', () => {
+ expect(accountMismatch(undefined, 'alice.near')).toMatch(/alice\.near/)
+ })
+})
+
+describe('afterSubmit', () => {
+ it('follows a landed deposit', () => {
+ expect(afterSubmit({ ok: true, hash: 'h', explorerUrl: 'u' }, 'solana')).toEqual({
+ next: 'tracking',
+ hash: 'h',
+ explorerUrl: 'u',
+ })
+ })
+
+ it('calls an unknown outcome pending', () => {
+ const result = afterSubmit({ ok: false, error: 'unsure', pending: true }, 'solana')
+ expect(result).toMatchObject({ next: 'pending' })
+ })
+
+ it('takes a clean refusal as a failure where this app does the sending', () => {
+ expect(afterSubmit({ ok: false, error: 'It was refused.' }, 'solana')).toEqual({
+ next: 'failed',
+ error: 'It was refused.',
+ })
+ })
+
+ it('does not show a bare error code as the reason', () => {
+ expect(afterSubmit({ ok: false, code: 'not_found', error: 'not_found' }, 'solana')).toEqual({
+ next: 'failed',
+ error: 'The deposit did not go through.',
+ })
+ })
+
+ it('never calls it a failure where the wallet already sent', () => {
+ expect(afterSubmit({ ok: false, error: 'refused' }, 'near').next).toBe('pending')
+ expect(afterSubmit({ ok: false, error: 'refused' }, 'evm').next).toBe('pending')
+ })
+
+ it.each([undefined, null, {}, { error: 'server_error' }, 'oops'])(
+ 'treats the unreadable answer %j as pending',
+ (answer) => {
+ expect(afterSubmit(answer, 'solana').next).toBe('pending')
+ }
+ )
+})
+
+describe('nextAttempt', () => {
+ const mint = (): string => 'new'
+
+ it('mints a key the first time', () => {
+ expect(nextAttempt(null, 'a', mint)).toEqual({ fingerprint: 'a', key: 'new' })
+ })
+
+ it('reuses the key on a retry of the same request', () => {
+ const held = { fingerprint: 'a', key: 'old' }
+ expect(nextAttempt(held, 'a', mint)).toBe(held)
+ })
+
+ it('mints a new key when the request changed', () => {
+ expect(nextAttempt({ fingerprint: 'a', key: 'old' }, 'b', mint).key).toBe('new')
+ })
+})
+
+describe('what may be offered', () => {
+ it('reviews only an order still waiting with no deposit', () => {
+ expect(orderPhase(order())).toBe('review')
+ expect(orderPhase(order({ depositHash: 'h' }))).toBe('tracking')
+ expect(orderPhase(order({ status: 'processing' }))).toBe('tracking')
+ expect(orderPhase(order({ status: 'paid' }))).toBe('tracking')
+ expect(orderPhase(order({ status: 'expired' }))).toBe('tracking')
+ })
+
+ it('offers signing only in review of an unpaid order', () => {
+ expect(canSign('review', order())).toBe(true)
+ expect(canSign('review', order({ depositHash: 'h' }))).toBe(false)
+ expect(canSign('review', order({ status: 'processing' }))).toBe(false)
+ expect(canSign('review', undefined)).toBe(false)
+ for (const phase of [
+ 'signing',
+ 'submitting',
+ 'tracking',
+ 'pending',
+ 'failed',
+ 'check',
+ ] as const) {
+ expect(canSign(phase, order())).toBe(false)
+ }
+ })
+
+ it('notices a deposit once the order shows one', () => {
+ expect(depositConfirmed(order())).toBe(false)
+ expect(depositConfirmed(order({ depositHash: 'h' }))).toBe(true)
+ expect(depositConfirmed(order({ status: 'processing' }))).toBe(true)
+ expect(depositConfirmed(order({ status: 'expired' }))).toBe(true)
+ })
+
+ it('locks the card once money may be moving, until the order ends', () => {
+ expect(isLocked('form', undefined)).toBe(false)
+ expect(isLocked('review', order())).toBe(false)
+ expect(isLocked('failed', order())).toBe(false)
+ expect(isLocked('signing', order())).toBe(true)
+ expect(isLocked('submitting', order())).toBe(true)
+ expect(isLocked('pending', order())).toBe(true)
+ expect(isLocked('check', order())).toBe(true)
+ expect(isLocked('check', order({ status: 'expired' }))).toBe(false)
+ expect(isLocked('tracking', order({ status: 'processing' }))).toBe(true)
+ expect(isLocked('tracking', order({ status: 'paid' }))).toBe(false)
+ expect(isLocked('pending', order({ status: 'refunded' }))).toBe(false)
+ })
+})
+
+describe('words', () => {
+ it('says a token must be swapped to USDC first', () => {
+ const reply = unsupportedTokenReply('SOL')
+ expect(reply).toMatch(/USDC/)
+ expect(reply).toMatch(/swap/i)
+ expect(reply).toMatch(/SOL/)
+ })
+
+ it('lists what can be paid out, and promises nothing else', () => {
+ const reply = unsupportedCurrencyReply('BRL', [
+ { code: 'KES', name: 'Kenyan shilling', symbol: 'KSh', country: 'Kenya' },
+ { code: 'UGX', name: 'Ugandan shilling', symbol: 'USh', country: 'Uganda' },
+ ])
+ expect(reply).toMatch(/BRL/)
+ expect(reply).toMatch(/Kenyan shilling \(KES\)/)
+ expect(reply).toMatch(/Ugandan shilling \(UGX\)/)
+ expect(reply).not.toMatch(/soon|coming|later|yet/i)
+ })
+
+ it('says plainly when there are no options', () => {
+ expect(unsupportedCurrencyReply('BRL', [])).toBe("Cash-outs aren't available right now.")
+ })
+
+ it('links a deposit to the explorer of its chain', () => {
+ expect(depositExplorerUrl('near', 'h')).toBe('https://nearblocks.io/txns/h')
+ expect(depositExplorerUrl('solana', 'h')).toBe('https://solscan.io/tx/h')
+ expect(depositExplorerUrl('sui', 'h')).toBe('https://suiscan.xyz/mainnet/tx/h')
+ expect(depositExplorerUrl('avalanche', 'h')).toBe('https://snowtrace.io/tx/h')
+ expect(depositExplorerUrl('stellar', 'h')).toBe('https://stellar.expert/explorer/public/tx/h')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-client.test.ts b/apps/dapp/lib/__tests__/weave-client.test.ts
new file mode 100644
index 00000000..6f8e29a3
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-client.test.ts
@@ -0,0 +1,387 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { createWeaveClient, quoteCostPercent, type WeaveOrderInput } from '../weave/client'
+
+const KEY = 'sk_test_never_shown'
+const PAYCREST =
+ 'Paycrest 400: {"status":"error","message":"Fiat currency BRL is not supported","data":null}'
+
+function json(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status })
+}
+
+function client(answer: (url: string, init: RequestInit) => Response | Promise) {
+ const fetchImpl = vi.fn(async (url: unknown, init?: RequestInit) =>
+ answer(String(url), init ?? {})
+ )
+ return {
+ fetchImpl,
+ weave: createWeaveClient({ apiKey: KEY, fetchImpl: fetchImpl as unknown as typeof fetch }),
+ }
+}
+
+const order: WeaveOrderInput = {
+ chain: 'near',
+ currency: 'KES',
+ amount: '25.5',
+ account: 'alice.near',
+ institutionCode: 'MPESA',
+ accountIdentifier: '254700000000',
+ accountName: 'JANE ADEYEMI',
+ reference: '3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456',
+ customer: { externalId: 'wallet:alice.near' },
+}
+
+const deposit = {
+ type: 'crypto_transfer',
+ depositAddress: 'weave.near',
+ depositMemo: null,
+ amount: '25.5',
+ token: 'USDC',
+ chain: 'NEAR',
+ expiresAt: '2026-11-04T12:00:00.000Z',
+}
+
+const placed = {
+ order: {
+ id: 'ord_1',
+ status: 'awaiting_deposit',
+ sourceAssetKey: 'crypto:NEAR:USDC',
+ destAssetKey: 'fiat:KES',
+ },
+ depositInstruction: deposit,
+ nextAction: { screen: 'collect_crypto', ...deposit },
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('configuration', () => {
+ it('is configured by WEAVE_SECRET_KEY and by no public variable', () => {
+ vi.stubEnv('NEXT_PUBLIC_WEAVE_SECRET_KEY', 'public')
+ vi.stubEnv('WEAVE_SECRET_KEY', '')
+ expect(createWeaveClient().configured).toBe(false)
+ vi.stubEnv('WEAVE_SECRET_KEY', KEY)
+ expect(createWeaveClient().configured).toBe(true)
+ })
+
+ it('makes no call without a key', async () => {
+ const fetchImpl = vi.fn()
+ const weave = createWeaveClient({ apiKey: '', fetchImpl: fetchImpl as unknown as typeof fetch })
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'unavailable' })
+ expect(await weave.institutions('KES')).toMatchObject({ ok: false, code: 'unavailable' })
+ expect(fetchImpl).not.toHaveBeenCalled()
+ })
+
+ it('sends the key in its header to the production base by default', async () => {
+ const { weave, fetchImpl } = client(() => json(200, { success: true, data: [] }))
+ await weave.institutions('KES')
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('https://api.paywithweave.com/api/v1/institutions?currency=KES')
+ expect((init.headers as Record)['x-secret-key']).toBe(KEY)
+ })
+})
+
+describe('institutions', () => {
+ it('returns the list', async () => {
+ const list = [{ name: 'M-Pesa', code: 'MPESA', type: 'mobile_money' }]
+ const { weave } = client(() => json(200, { success: true, data: list }))
+ expect(await weave.institutions('KES')).toEqual({ ok: true, value: list })
+ })
+
+ it('fails closed on a shape it does not recognise', async () => {
+ const { weave } = client(() => json(200, { success: true, data: [{ code: 'X' }] }))
+ expect(await weave.institutions('KES')).toMatchObject({ ok: false, code: 'unexpected' })
+ })
+
+ it('fails closed on a body with no success flag', async () => {
+ const { weave } = client(() => json(200, { data: [] }))
+ expect(await weave.institutions('KES')).toMatchObject({ ok: false, code: 'unexpected' })
+ })
+})
+
+describe('verifyAccount', () => {
+ it('posts the details and returns the name', async () => {
+ const { weave, fetchImpl } = client(() => json(200, { success: true, data: 'JANE ADEYEMI' }))
+ const result = await weave.verifyAccount({
+ institution: 'MPESA',
+ accountIdentifier: '254700000000',
+ currency: 'KES',
+ })
+ expect(result).toEqual({ ok: true, value: { accountName: 'JANE ADEYEMI' } })
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
+ expect(url).toMatch(/\/institutions\/verify$/)
+ expect(JSON.parse(init.body as string)).toEqual({
+ institution: 'MPESA',
+ accountIdentifier: '254700000000',
+ currency: 'KES',
+ })
+ })
+
+ it('refuses an empty name', async () => {
+ const { weave } = client(() => json(200, { success: true, data: ' ' }))
+ expect(
+ await weave.verifyAccount({ institution: 'A', accountIdentifier: '1', currency: 'KES' })
+ ).toMatchObject({ ok: false, code: 'unexpected' })
+ })
+
+ it('says it could not be verified without echoing the provider', async () => {
+ const { weave } = client(() => json(400, { success: false, error: PAYCREST }))
+ const result = await weave.verifyAccount({
+ institution: 'A',
+ accountIdentifier: '1',
+ currency: 'KES',
+ })
+ expect(result).toMatchObject({ ok: false, code: 'rejected' })
+ expect(JSON.stringify(result)).not.toContain('Paycrest')
+ })
+})
+
+describe('quote', () => {
+ const body = {
+ success: true,
+ data: {
+ estimatedDest: 3200.5,
+ breakdown: { netUsd: 24.75, appFeeUsd: 0.1, bridgeFeeUsd: 0.15 },
+ route: {
+ steps: [{ provider: 'paycrest' }],
+ totalExpectedFeeBps: 12,
+ totalExpectedSecs: 90,
+ },
+ },
+ }
+
+ it('asks for the chain and currency as asset keys', async () => {
+ const { weave, fetchImpl } = client(() => json(200, body))
+ const result = await weave.quote({ chain: 'near', currency: 'KES', amount: '25' })
+ const url = new URL(String(fetchImpl.mock.calls[0]?.[0]))
+ expect(url.pathname).toBe('/api/v1/quotes')
+ expect(url.searchParams.get('from')).toBe('crypto:NEAR:USDC')
+ expect(url.searchParams.get('to')).toBe('fiat:KES')
+ expect(url.searchParams.get('amount')).toBe('25')
+ expect(result).toEqual({ ok: true, value: { destAmount: 3200.5, netUsd: 24.75, seconds: 90 } })
+ })
+
+ it('needs none of the fields that are only in the docs', async () => {
+ const { weave } = client(() => json(200, body))
+ expect((await weave.quote({ chain: 'near', currency: 'KES', amount: '25' })).ok).toBe(true)
+ })
+
+ it('fails closed when netUsd is missing, since the cost cannot be worked out', async () => {
+ const { weave } = client(() =>
+ json(200, {
+ success: true,
+ data: { estimatedDest: 1, breakdown: {}, route: { totalExpectedSecs: 1 } },
+ })
+ )
+ expect(await weave.quote({ chain: 'near', currency: 'KES', amount: '25' })).toMatchObject({
+ ok: false,
+ code: 'unexpected',
+ })
+ })
+
+ it('never shows the raw failure text', async () => {
+ const { weave } = client(() => json(400, { success: false, error: PAYCREST }))
+ const result = await weave.quote({ chain: 'near', currency: 'KES', amount: '25' })
+ expect(result).toMatchObject({ ok: false, code: 'rejected' })
+ expect(JSON.stringify(result)).not.toMatch(/Paycrest|BRL/)
+ })
+
+ it('treats a success flag of false on a 200 as refused', async () => {
+ const { weave } = client(() => json(200, { success: false, error: PAYCREST }))
+ expect(await weave.quote({ chain: 'near', currency: 'KES', amount: '25' })).toMatchObject({
+ ok: false,
+ code: 'rejected',
+ })
+ })
+})
+
+describe('quoteCostPercent', () => {
+ it('is what the amount lost on the way to netUsd', () => {
+ expect(quoteCostPercent('100', 96)).toBeCloseTo(4)
+ expect(quoteCostPercent('25', 24.75)).toBeCloseTo(1)
+ })
+
+ it('is above any limit for an amount that cannot be read', () => {
+ expect(quoteCostPercent('0', 1)).toBe(Number.POSITIVE_INFINITY)
+ expect(quoteCostPercent('nope', 1)).toBe(Number.POSITIVE_INFINITY)
+ })
+})
+
+describe('createOrder', () => {
+ it('sends the documented body', async () => {
+ const { weave, fetchImpl } = client(() => json(200, placed))
+ await weave.createOrder(order)
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
+ expect(url).toMatch(/\/orders$/)
+ expect(init.method).toBe('POST')
+ expect(JSON.parse(init.body as string)).toEqual({
+ source: {
+ inline: {
+ kind: 'crypto_wallet',
+ assetKey: 'crypto:NEAR:USDC',
+ walletAddress: 'alice.near',
+ },
+ },
+ dest: {
+ inline: {
+ kind: 'bank_account',
+ assetKey: 'fiat:KES',
+ bankInstitution: 'MPESA',
+ bankAccountIdentifier: '254700000000',
+ bankAccountName: 'JANE ADEYEMI',
+ label: 'Cash-out',
+ },
+ },
+ amount: '25.5',
+ amountIn: 'source',
+ flow: 'external',
+ refundAddress: 'alice.near',
+ reference: order.reference,
+ customer: { externalId: 'wallet:alice.near' },
+ })
+ })
+
+ const expected = {
+ ok: true,
+ value: {
+ id: 'ord_1',
+ status: 'awaiting_deposit',
+ sourceAssetKey: 'crypto:NEAR:USDC',
+ destAssetKey: 'fiat:KES',
+ deposit: {
+ address: 'weave.near',
+ memo: undefined,
+ amount: '25.5',
+ token: 'USDC',
+ chain: 'NEAR',
+ expiresAt: '2026-11-04T12:00:00.000Z',
+ },
+ },
+ }
+
+ it('accepts the answer unwrapped', async () => {
+ const { weave } = client(() => json(200, placed))
+ expect(await weave.createOrder(order)).toEqual(expected)
+ })
+
+ it('accepts the answer wrapped in data', async () => {
+ const { weave } = client(() => json(201, { success: true, data: placed }))
+ expect(await weave.createOrder(order)).toEqual(expected)
+ })
+
+ it('accepts nextAction alone, where the expiry is not given', async () => {
+ const { weave } = client(() =>
+ json(200, {
+ order: placed.order,
+ nextAction: {
+ screen: 'collect_crypto',
+ depositAddress: 'weave.near',
+ amount: 25.5,
+ token: 'USDC',
+ chain: 'NEAR',
+ depositMemo: 'M1',
+ },
+ })
+ )
+ const result = await weave.createOrder(order)
+ expect(result).toMatchObject({
+ ok: true,
+ value: { deposit: { address: 'weave.near', amount: '25.5', memo: 'M1' } },
+ })
+ if (result.ok) expect(result.value.deposit.expiresAt).toBeUndefined()
+ })
+
+ it('reads an empty memo as no memo', async () => {
+ const { weave } = client(() =>
+ json(200, { ...placed, depositInstruction: { ...deposit, depositMemo: '' } })
+ )
+ const result = await weave.createOrder(order)
+ if (!result.ok) throw new Error('expected an order')
+ expect(result.value.deposit.memo).toBeUndefined()
+ })
+
+ it.each([
+ ['no deposit details', { order: placed.order }],
+ ['no order', { depositInstruction: deposit }],
+ ['no address', { ...placed, depositInstruction: { ...deposit, depositAddress: '' } }],
+ [
+ 'an expiry that is not a date',
+ { ...placed, depositInstruction: { ...deposit, expiresAt: 'soon' } },
+ ],
+ ])('fails closed on %s', async (_name, body) => {
+ const { weave } = client(() => json(200, body))
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'unexpected' })
+ })
+
+ it('says a duplicate reference apart from any other refusal', async () => {
+ const { weave } = client(() => json(409, { success: false, error: 'duplicate reference' }))
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'duplicate' })
+ })
+
+ it('reports a refusal without the provider text', async () => {
+ const { weave } = client(() => json(400, { success: false, error: PAYCREST }))
+ const result = await weave.createOrder(order)
+ expect(result).toMatchObject({ ok: false, code: 'rejected' })
+ expect(JSON.stringify(result)).not.toContain('Paycrest')
+ })
+
+ it('reports no answer as unknown, because the order may exist', async () => {
+ const { weave } = client(() => {
+ throw new Error('socket hang up')
+ })
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'unknown' })
+ })
+
+ it('reports a server error as unknown, because the order may exist', async () => {
+ const { weave } = client(() => json(502, {}))
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'unknown' })
+ })
+
+ it('reports a throttle as unavailable, because nothing was accepted', async () => {
+ const { weave } = client(() => json(429, {}))
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'unavailable' })
+ })
+
+ it('gives up at its timeout and calls that unknown', async () => {
+ const fetchImpl = ((_url: string, init: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init.signal?.addEventListener('abort', () => reject(new Error('aborted')))
+ })) as unknown as typeof fetch
+ const weave = createWeaveClient({ apiKey: KEY, fetchImpl, timeoutMs: 5 })
+ expect(await weave.createOrder(order)).toMatchObject({ ok: false, code: 'unknown' })
+ })
+})
+
+describe('getOrder', () => {
+ it('returns the status', async () => {
+ const { weave, fetchImpl } = client(() =>
+ json(200, { success: true, data: { id: 'ord/1', status: 'processing', steps: [] } })
+ )
+ expect(await weave.getOrder('ord/1')).toEqual({
+ ok: true,
+ value: { id: 'ord/1', status: 'processing' },
+ })
+ expect(String(fetchImpl.mock.calls[0]?.[0])).toMatch(/\/orders\/ord%2F1$/)
+ })
+
+ it('fails closed when the answer is about another order', async () => {
+ const { weave } = client(() =>
+ json(200, { success: true, data: { id: 'ord_2', status: 'completed' } })
+ )
+ expect(await weave.getOrder('ord_1')).toMatchObject({ ok: false, code: 'unexpected' })
+ })
+
+ it('fails closed with no status', async () => {
+ const { weave } = client(() => json(200, { success: true, data: { id: 'ord_1' } }))
+ expect(await weave.getOrder('ord_1')).toMatchObject({ ok: false, code: 'unexpected' })
+ })
+
+ it('calls no answer unavailable, since a read changes nothing', async () => {
+ const { weave } = client(() => {
+ throw new Error('down')
+ })
+ expect(await weave.getOrder('ord_1')).toMatchObject({ ok: false, code: 'unavailable' })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-corridors.test.ts b/apps/dapp/lib/__tests__/weave-corridors.test.ts
new file mode 100644
index 00000000..3263b1fa
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-corridors.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+
+import { cashOutPrompt, CORRIDORS, isCurrencyAllowed, liveCurrencies } from '../weave/corridors'
+
+describe('cashOutPrompt', () => {
+ const live = { WEAVE_SECRET_KEY: 'sk', WEAVE_CASHOUT_CURRENCIES: 'NGN,KES,UGX,TZS' }
+
+ it('tells the model to ask and lists each currency', () => {
+ const prompt = cashOutPrompt('sui', live)
+ expect(prompt).toMatch(/never assume/i)
+ expect(prompt).toMatch(/ask which/i)
+ for (const name of ['NGN', 'KES', 'UGX', 'TZS']) expect(prompt).toContain(name)
+ })
+
+ it('lists naira on stellar, which Linq pays, even when Weave only has the others', () => {
+ const prompt = cashOutPrompt('stellar', { ...live, WEAVE_CASHOUT_CURRENCIES: 'KES' })
+ expect(prompt).toContain('NGN')
+ expect(prompt).toContain('KES')
+ })
+
+ it('says stellar pays only naira when Weave is not set up', () => {
+ const prompt = cashOutPrompt('stellar', {})
+ expect(prompt).toMatch(/only to Nigerian naira/)
+ expect(prompt).not.toContain('KES')
+ })
+
+ it('names nothing where no cash-out exists', () => {
+ expect(cashOutPrompt('sui', {})).toBe('')
+ expect(cashOutPrompt('arc', live)).toBe('')
+ expect(cashOutPrompt('monad', live)).toBe('')
+ })
+})
+
+describe('liveCurrencies', () => {
+ it('is naira only when nothing is set, since no other payout has been tested', () => {
+ expect(liveCurrencies({})).toEqual(['NGN'])
+ expect(liveCurrencies({ WEAVE_CASHOUT_CURRENCIES: ' ' })).toEqual(['NGN'])
+ })
+
+ it('reads a comma list and tolerates spaces and case', () => {
+ expect(liveCurrencies({ WEAVE_CASHOUT_CURRENCIES: 'kes, ugx ,TZS' })).toEqual([
+ 'KES',
+ 'UGX',
+ 'TZS',
+ ])
+ })
+
+ it('ignores anything outside the four known and lists each once', () => {
+ expect(liveCurrencies({ WEAVE_CASHOUT_CURRENCIES: 'BRL,KES,KES,' })).toEqual(['KES'])
+ })
+
+ it('opens nothing when the list names nothing known, rather than falling back', () => {
+ expect(liveCurrencies({ WEAVE_CASHOUT_CURRENCIES: 'BRL' })).toEqual([])
+ })
+})
+
+describe('isCurrencyAllowed', () => {
+ const all = { WEAVE_CASHOUT_CURRENCIES: 'NGN,KES,UGX,TZS' }
+
+ it('keeps Stellar naira on the existing Linq flow', () => {
+ expect(isCurrencyAllowed('stellar', 'NGN', all)).toBe(false)
+ expect(isCurrencyAllowed('stellar', 'KES', all)).toBe(true)
+ expect(isCurrencyAllowed('solana', 'NGN', all)).toBe(true)
+ })
+
+ it('refuses a currency that is known but not live', () => {
+ expect(isCurrencyAllowed('near', 'KES', {})).toBe(false)
+ expect(isCurrencyAllowed('near', 'NGN', {})).toBe(true)
+ })
+
+ it('refuses a currency it does not know', () => {
+ expect(isCurrencyAllowed('near', 'BRL', { WEAVE_CASHOUT_CURRENCIES: 'BRL' })).toBe(false)
+ })
+
+ it('leaves Stellar with nothing by default', () => {
+ expect(isCurrencyAllowed('stellar', 'NGN', {})).toBe(false)
+ expect(isCurrencyAllowed('stellar', 'KES', {})).toBe(false)
+ })
+})
+
+describe('CORRIDORS', () => {
+ it('names each country and symbol', () => {
+ expect(CORRIDORS.NGN).toEqual({
+ code: 'NGN',
+ name: 'Nigerian naira',
+ country: 'Nigeria',
+ symbol: '₦',
+ })
+ expect(CORRIDORS.KES.country).toBe('Kenya')
+ expect(CORRIDORS.UGX.country).toBe('Uganda')
+ expect(CORRIDORS.TZS.country).toBe('Tanzania')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts
new file mode 100644
index 00000000..0571d0c9
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts
@@ -0,0 +1,595 @@
+import { TransactionExecutionError, UserRejectedRequestError, custom } from 'viem'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { ChainActionRefused, type SigningPayload } from '../chains/types'
+import {
+ avalancheClient,
+ avalancheDeposit,
+ createAvalancheDeposit,
+ type AvalancheClient,
+ type DepositReceipt,
+} from '../weave/deposit/avalanche'
+import type { DepositExpectation } from '../weave/types'
+
+const wallet = vi.hoisted(() => ({
+ account: { status: 'connected', chainId: 43114 } as {
+ status: string
+ chainId: number | undefined
+ },
+ calls: [] as unknown[][],
+ sendError: undefined as unknown,
+ switchError: undefined as unknown,
+}))
+
+vi.mock('wagmi/actions', () => ({
+ getAccount: () => wallet.account,
+ switchChain: async (_config: unknown, args: unknown) => {
+ wallet.calls.push(['switch', args])
+ if (wallet.switchError !== undefined) throw wallet.switchError
+ return { id: 43114 }
+ },
+ sendTransaction: async (_config: unknown, args: unknown) => {
+ wallet.calls.push(['send', args])
+ if (wallet.sendError !== undefined) throw wallet.sendError
+ return HASH_FROM_WALLET
+ },
+}))
+vi.mock('../wagmi.config', () => ({ arcTestnet: { id: 5042002 }, wagmiConfig: {} }))
+
+const HASH_FROM_WALLET = `0x${'c'.repeat(64)}`
+
+const USDC = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'
+const OTHER_TOKEN = '0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664'
+const ACCOUNT = '0x1111111111111111111111111111111111111111'
+const DEPOSIT = '0x2222222222222222222222222222222222222222'
+const OTHER = '0x3333333333333333333333333333333333333333'
+const ZERO = '0x0000000000000000000000000000000000000000'
+const HASH = `0x${'a'.repeat(64)}`
+const AMOUNT_UNITS = BigInt(12_500_000)
+
+const word = (hexDigits: string): string => hexDigits.padStart(64, '0')
+const addressWord = (address: string): string => word(address.slice(2).toLowerCase())
+
+// 12.5 USDC at six places is 12500000, which is 0xbebc20.
+const TRANSFER_DATA = `0xa9059cbb${addressWord(DEPOSIT)}${word('bebc20')}`
+
+const plain: DepositExpectation = {
+ account: ACCOUNT,
+ depositAddress: DEPOSIT,
+ memo: undefined,
+ amount: '12.5',
+}
+
+function payload(change: Partial> = {}): SigningPayload {
+ return { family: 'evm', chainId: 43114, to: USDC, data: TRANSFER_DATA, value: '0', ...change }
+}
+
+function fakeClient(change: Partial = {}): AvalancheClient {
+ return {
+ usdcBalance: async () => AMOUNT_UNITS,
+ avaxBalance: async () => BigInt(1),
+ waitForReceipt: async () => {
+ throw new Error('no receipt was arranged for this test')
+ },
+ ...change,
+ }
+}
+
+const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
+
+function transferLog(
+ change: { address?: string; from?: string; to?: string; hexAmount?: string } = {}
+): DepositReceipt['logs'][number] {
+ return {
+ address: change.address ?? USDC,
+ blockHash: `0x${'b'.repeat(64)}`,
+ blockNumber: BigInt(1),
+ data: `0x${word(change.hexAmount ?? 'bebc20')}`,
+ logIndex: 0,
+ removed: false,
+ topics: [
+ TRANSFER_TOPIC,
+ `0x${addressWord(change.from ?? ACCOUNT)}`,
+ `0x${addressWord(change.to ?? DEPOSIT)}`,
+ ],
+ transactionHash: HASH,
+ transactionIndex: 0,
+ } as unknown as DepositReceipt['logs'][number]
+}
+
+function receipt(change: Partial = {}): DepositReceipt {
+ return {
+ status: 'success',
+ from: ACCOUNT,
+ to: USDC,
+ logs: [transferLog()],
+ ...change,
+ } as DepositReceipt
+}
+
+function refusalOf(run: () => unknown): Error {
+ try {
+ run()
+ } catch (e) {
+ return e as Error
+ }
+ throw new Error('expected a refusal, and nothing was refused')
+}
+
+function expectRefused(run: () => unknown, about: RegExp): void {
+ const e = refusalOf(run)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e.message).toMatch(about)
+ expect(e.message).toMatch(/Nothing was signed or sent\.$/)
+}
+
+afterEach(() => {
+ vi.useRealTimers()
+ wallet.account = { status: 'connected', chainId: 43114 }
+ wallet.calls = []
+ wallet.sendError = undefined
+ wallet.switchError = undefined
+})
+
+describe('avalancheDeposit: what it says about itself', () => {
+ it('carries six decimals and no memo', () => {
+ expect(avalancheDeposit.chain).toBe('avalanche')
+ expect(avalancheDeposit.usdcDecimals).toBe(6)
+ expect(avalancheDeposit.supportsMemo).toBe(false)
+ })
+
+ it('takes addresses in any casing, even with a checksum that does not hold', () => {
+ const badChecksum = '0xb97ef9EF8734C71904D8002F8b6Bc66Dd9c48a6F'
+ expect(avalancheDeposit.isAccount(badChecksum)).toBe(true)
+ expect(avalancheDeposit.isDepositAddress(badChecksum)).toBe(true)
+ expect(avalancheDeposit.isDepositAddress(DEPOSIT)).toBe(true)
+ })
+
+ it('refuses what is not an address, and the zero address', () => {
+ for (const check of [avalancheDeposit.isAccount, avalancheDeposit.isDepositAddress]) {
+ expect(check(ZERO)).toBe(false)
+ expect(check('')).toBe(false)
+ expect(check('0x1234')).toBe(false)
+ expect(check('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false)
+ }
+ })
+
+ it('refuses the USDC contract as a deposit address, in any casing', () => {
+ expect(avalancheDeposit.isDepositAddress(USDC)).toBe(false)
+ expect(avalancheDeposit.isDepositAddress(USDC.toLowerCase())).toBe(false)
+ })
+})
+
+describe('avalancheDeposit.assertSafe', () => {
+ it('admits exactly the transfer the order asked for', () => {
+ expect(() => avalancheDeposit.assertSafe(payload(), plain)).not.toThrow()
+ })
+
+ it('compares the token contract without regard to casing', () => {
+ expect(() =>
+ avalancheDeposit.assertSafe(payload({ to: USDC.toLowerCase() }), plain)
+ ).not.toThrow()
+ })
+
+ it('compares the recipient without regard to casing', () => {
+ const mixed = '0xAbCdEf0123456789aBcDeF0123456789abcdef01'
+ const data = `0xa9059cbb${addressWord(mixed)}${word('bebc20')}`
+ expect(() =>
+ avalancheDeposit.assertSafe(payload({ data }), { ...plain, depositAddress: mixed })
+ ).not.toThrow()
+ })
+
+ it.each<[string, Partial>, RegExp]>([
+ [
+ 'another recipient',
+ { data: `0xa9059cbb${addressWord(OTHER)}${word('bebc20')}` },
+ /deposit address/i,
+ ],
+ ['a larger amount', { data: `0xa9059cbb${addressWord(DEPOSIT)}${word('bebc21')}` }, /amount/i],
+ ['a smaller amount', { data: `0xa9059cbb${addressWord(DEPOSIT)}${word('bebc1f')}` }, /amount/i],
+ ['another token contract', { to: OTHER_TOKEN }, /USDC/],
+ ['an approve', { data: `0x095ea7b3${addressWord(DEPOSIT)}${word('bebc20')}` }, /transfer/i],
+ [
+ 'a transferFrom',
+ {
+ data: `0x23b872dd${addressWord(ACCOUNT)}${addressWord(DEPOSIT)}${word('bebc20')}`,
+ },
+ /transfer/i,
+ ],
+ ['bytes after the transfer', { data: `${TRANSFER_DATA}deadbeef` }, /extra|exactly/i],
+ [
+ 'dirty padding before the address',
+ { data: `0xa9059cbb${'f'.repeat(24)}${'2'.repeat(40)}${word('bebc20')}` },
+ /extra|exactly/i,
+ ],
+ ['calldata cut short', { data: TRANSFER_DATA.slice(0, 40) }, /transfer/i],
+ ['no calldata', { data: '0x' }, /transfer/i],
+ ['text that is not calldata', { data: 'hello' }, /transfer/i],
+ ['another chain', { chainId: 1 }, /Avalanche/],
+ ['the Avalanche testnet', { chainId: 43113 }, /Avalanche/],
+ ['a non-zero value', { value: '1' as '0' }, /AVAX|value/i],
+ ])('refuses %s', (_name, change, about) => {
+ expectRefused(() => avalancheDeposit.assertSafe(payload(change), plain), about)
+ })
+
+ it('refuses another chain’s payload', () => {
+ expectRefused(
+ () =>
+ avalancheDeposit.assertSafe({ family: 'stellar', xdr: 'AAAA', account: ACCOUNT }, plain),
+ /Avalanche/
+ )
+ })
+
+ it('refuses an order with a memo, which this chain cannot carry', () => {
+ expectRefused(() => avalancheDeposit.assertSafe(payload(), { ...plain, memo: '42' }), /memo/i)
+ })
+
+ it('refuses an order that names the zero address, even if the calldata matches', () => {
+ const data = `0xa9059cbb${addressWord(ZERO)}${word('bebc20')}`
+ expectRefused(
+ () => avalancheDeposit.assertSafe(payload({ data }), { ...plain, depositAddress: ZERO }),
+ /deposit address/i
+ )
+ })
+})
+
+describe('avalancheDeposit.build', () => {
+ it('builds the one transfer, when the account can pay it and its gas', async () => {
+ const reads: string[] = []
+ const deposit = createAvalancheDeposit(() =>
+ fakeClient({
+ usdcBalance: async (account) => {
+ reads.push(`usdc ${account}`)
+ return AMOUNT_UNITS
+ },
+ avaxBalance: async (account) => {
+ reads.push(`avax ${account}`)
+ return BigInt(1)
+ },
+ })
+ )
+ await expect(deposit.build(plain)).resolves.toEqual({
+ family: 'evm',
+ chainId: 43114,
+ to: USDC,
+ data: TRANSFER_DATA,
+ value: '0',
+ })
+ expect(reads.sort()).toEqual([`avax ${ACCOUNT}`, `usdc ${ACCOUNT}`])
+ })
+
+ async function refusal(deposit: typeof avalancheDeposit, expectation = plain): Promise {
+ return deposit.build(expectation).then(
+ () => {
+ throw new Error('expected a refusal')
+ },
+ (e: Error) => e
+ )
+ }
+
+ it('refuses one unit short of the amount', async () => {
+ const e = await refusal(
+ createAvalancheDeposit(() =>
+ fakeClient({ usdcBalance: async () => AMOUNT_UNITS - BigInt(1) })
+ )
+ )
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e.message).toMatch(/USDC/)
+ expect(e.message).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses an account with no AVAX for gas', async () => {
+ const e = await refusal(
+ createAvalancheDeposit(() => fakeClient({ avaxBalance: async () => BigInt(0) }))
+ )
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e.message).toMatch(/AVAX/)
+ expect(e.message).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses when a balance cannot be read, rather than guessing', async () => {
+ const e = await refusal(
+ createAvalancheDeposit(() =>
+ fakeClient({
+ usdcBalance: async () => {
+ throw new Error('rpc down')
+ },
+ })
+ )
+ )
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e.message).not.toMatch(/rpc down/)
+ expect(e.message).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it.each<[string, Partial, RegExp]>([
+ ['a memo', { memo: '42' }, /memo/i],
+ ['an account that is not one', { account: 'nope' }, /account/i],
+ ['the zero address as account', { account: ZERO }, /account/i],
+ ['the zero address as deposit', { depositAddress: ZERO }, /deposit address/i],
+ ['the USDC contract as deposit', { depositAddress: USDC }, /deposit address/i],
+ ['more places than USDC has', { amount: '1.0000001' }, /decimal/i],
+ ['a zero amount', { amount: '0' }, /more than zero/i],
+ ])('refuses %s before reading anything', async (_name, change, about) => {
+ const read = vi.fn(async () => BigInt(0))
+ const deposit = createAvalancheDeposit(() =>
+ fakeClient({ usdcBalance: read, avaxBalance: read })
+ )
+ const e = await refusal(deposit, { ...plain, ...change })
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e.message).toMatch(about)
+ expect(read).not.toHaveBeenCalled()
+ })
+})
+
+describe('avalancheDeposit.submit', () => {
+ const signed = { family: 'evm' as const, hash: HASH }
+ const withReceipt = (r: DepositReceipt | Error) =>
+ createAvalancheDeposit(() =>
+ fakeClient({
+ waitForReceipt: async (hash) => {
+ expect(hash).toBe(HASH)
+ if (r instanceof Error) throw r
+ return r
+ },
+ })
+ )
+
+ it('confirms a transfer that paid this order', async () => {
+ await expect(withReceipt(receipt()).submit(signed, plain)).resolves.toEqual({
+ ok: true,
+ hash: HASH,
+ explorerUrl: `https://snowtrace.io/tx/${HASH}`,
+ })
+ })
+
+ it('matches addresses without regard to casing', async () => {
+ const r = receipt({ from: ACCOUNT, to: USDC.toLowerCase() as `0x${string}` })
+ await expect(withReceipt(r).submit(signed, plain)).resolves.toMatchObject({ ok: true })
+ })
+
+ it('says only gas moved when the transfer reverted', async () => {
+ const result = await withReceipt(receipt({ status: 'reverted', logs: [] })).submit(
+ signed,
+ plain
+ )
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/did not move/i) })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it.each<[string, DepositReceipt]>([
+ ['another sender', receipt({ from: OTHER as `0x${string}` })],
+ ['a call to another contract', receipt({ to: OTHER_TOKEN as `0x${string}` })],
+ ['a contract creation', receipt({ to: null })],
+ ['no logs', receipt({ logs: [] })],
+ ['a log from another token', receipt({ logs: [transferLog({ address: OTHER_TOKEN })] })],
+ ['a transfer to someone else', receipt({ logs: [transferLog({ to: OTHER })] })],
+ ['a transfer from someone else', receipt({ logs: [transferLog({ from: OTHER })] })],
+ ['a transfer of another amount', receipt({ logs: [transferLog({ hexAmount: 'bebc21' })] })],
+ ['a transfer of less', receipt({ logs: [transferLog({ hexAmount: 'bebc1f' })] })],
+ ])('refuses a successful transaction with %s as not this deposit', async (_name, r) => {
+ const result = await withReceipt(r).submit(signed, plain)
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/not this order/i) })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it.each([
+ ['a timeout', new Error('Timed out while waiting for transaction')],
+ ['a transaction that is not found', new Error('Transaction could not be found')],
+ ])('treats %s as pending, naming the hash', async (_name, thrown) => {
+ const result = await withReceipt(thrown).submit(signed, plain)
+ expect(result).toMatchObject({ ok: false, pending: true })
+ if (result.ok) throw new Error('expected pending')
+ expect(result.error).toContain(HASH)
+ expect(result.error).toMatch(/do not send/i)
+ })
+
+ it.each([
+ ['too short', '0xabc'],
+ ['without its prefix', 'a'.repeat(64)],
+ ['with a character that is not hex', `0x${'g'.repeat(64)}`],
+ ['with trailing text', `${HASH}zz`],
+ ])('refuses a hash that is %s, without asking the chain', async (_name, hash) => {
+ const wait = vi.fn(async () => receipt())
+ const deposit = createAvalancheDeposit(() => fakeClient({ waitForReceipt: wait }))
+ await expect(deposit.submit({ family: 'evm', hash }, plain)).rejects.toBeInstanceOf(
+ ChainActionRefused
+ )
+ expect(wait).not.toHaveBeenCalled()
+ })
+
+ it('refuses another chain’s result, without asking the chain', async () => {
+ const wait = vi.fn(async () => receipt())
+ const deposit = createAvalancheDeposit(() => fakeClient({ waitForReceipt: wait }))
+ await expect(
+ deposit.submit({ family: 'stellar', signedXdr: 'AAAA' }, plain)
+ ).rejects.toBeInstanceOf(ChainActionRefused)
+ expect(wait).not.toHaveBeenCalled()
+ })
+})
+
+describe('avalancheClient, over a local transport', () => {
+ const blockHash = `0x${'b'.repeat(64)}`
+
+ const rpcReceipt = {
+ blockHash,
+ blockNumber: '0x1',
+ contractAddress: null,
+ cumulativeGasUsed: '0x1',
+ effectiveGasPrice: '0x1',
+ from: ACCOUNT,
+ gasUsed: '0x1',
+ logs: [
+ {
+ address: USDC.toLowerCase(),
+ blockHash,
+ blockNumber: '0x1',
+ data: `0x${word('bebc20')}`,
+ logIndex: '0x0',
+ removed: false,
+ topics: [TRANSFER_TOPIC, `0x${addressWord(ACCOUNT)}`, `0x${addressWord(DEPOSIT)}`],
+ transactionHash: HASH,
+ transactionIndex: '0x0',
+ },
+ ],
+ logsBloom: `0x${'0'.repeat(512)}`,
+ status: '0x1',
+ to: USDC.toLowerCase(),
+ transactionHash: HASH,
+ transactionIndex: '0x0',
+ type: '0x2',
+ }
+
+ function transport(answers: Record, seen: unknown[] = []) {
+ return custom({
+ request: async ({ method, params }: { method: string; params?: unknown }) => {
+ seen.push({ method, params })
+ return answers[method] ?? null
+ },
+ })
+ }
+
+ it('reads USDC from the USDC contract and AVAX as the native balance', async () => {
+ const seen: { method: string; params: unknown[] }[] = []
+ const client = avalancheClient(
+ transport(
+ {
+ eth_call: `0x${word('bebc20')}`,
+ eth_getBalance: '0x5',
+ },
+ seen
+ )
+ )
+ await expect(client.usdcBalance(ACCOUNT)).resolves.toBe(AMOUNT_UNITS)
+ await expect(client.avaxBalance(ACCOUNT)).resolves.toBe(BigInt(5))
+ const call = seen.find((s) => s.method === 'eth_call')?.params[0] as {
+ to: string
+ data: string
+ }
+ expect(call.to.toLowerCase()).toBe(USDC.toLowerCase())
+ expect(call.data).toBe(`0x70a08231${addressWord(ACCOUNT)}`)
+ const balance = seen.find((s) => s.method === 'eth_getBalance')?.params[0] as string
+ expect(balance.toLowerCase()).toBe(ACCOUNT)
+ })
+
+ it('confirms a real receipt shape end to end', async () => {
+ const deposit = createAvalancheDeposit(() =>
+ avalancheClient(
+ transport({
+ eth_blockNumber: '0x2',
+ eth_getTransactionReceipt: rpcReceipt,
+ })
+ )
+ )
+ await expect(deposit.submit({ family: 'evm', hash: HASH }, plain)).resolves.toMatchObject({
+ ok: true,
+ hash: HASH,
+ })
+ })
+
+ it('gives up on a receipt that never comes at about 20 seconds, as pending', async () => {
+ vi.useFakeTimers()
+ const deposit = createAvalancheDeposit(() =>
+ avalancheClient(transport({ eth_blockNumber: '0x2' }))
+ )
+ let settled: unknown
+ const running = deposit.submit({ family: 'evm', hash: HASH }, plain).then((r) => {
+ settled = r
+ return r
+ })
+ await vi.advanceTimersByTimeAsync(15_000)
+ expect(settled).toBeUndefined()
+ await vi.advanceTimersByTimeAsync(10_000)
+ await expect(running).resolves.toMatchObject({ ok: false, pending: true })
+ })
+})
+
+describe('the EVM adapters’ signPrepared', () => {
+ async function adapters() {
+ return import('../adapters/evm-adapter')
+ }
+
+ it('exists on Avalanche alone', async () => {
+ const { arcAdapter, avalancheAdapter, monadAdapter } = await adapters()
+ expect(avalancheAdapter.signPrepared).toBeTypeOf('function')
+ expect(arcAdapter.signPrepared).toBeUndefined()
+ expect(monadAdapter.signPrepared).toBeUndefined()
+ // The first import of the adapter pulls in the wallet libraries, which takes
+ // longer than the default when the whole suite is running.
+ }, 30_000)
+
+ it('sends the transfer as prepared, when the wallet is already on Avalanche', async () => {
+ const { avalancheAdapter } = await adapters()
+ const out = await avalancheAdapter.signPrepared?.(payload())
+ expect(out).toEqual({ ok: true, signed: { family: 'evm', hash: HASH_FROM_WALLET } })
+ expect(wallet.calls).toEqual([
+ ['send', { to: USDC, data: TRANSFER_DATA, value: BigInt(0), chainId: 43114 }],
+ ])
+ })
+
+ it('switches first, when the wallet is elsewhere', async () => {
+ wallet.account = { status: 'connected', chainId: 1 }
+ const { avalancheAdapter } = await adapters()
+ await avalancheAdapter.signPrepared?.(payload())
+ expect(wallet.calls.map((c) => c[0])).toEqual(['switch', 'send'])
+ expect(wallet.calls[0]?.[1]).toEqual({ chainId: 43114 })
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another family', { family: 'stellar', xdr: 'AAAA', account: ACCOUNT }],
+ ['another chain id', payload({ chainId: 1 })],
+ ])('refuses %s without touching the wallet', async (_name, p) => {
+ const { avalancheAdapter } = await adapters()
+ const out = await avalancheAdapter.signPrepared?.(p)
+ expect(out).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(out).toHaveProperty('detail', expect.stringMatching(/.+/))
+ expect(wallet.calls).toEqual([])
+ })
+
+ it('asks for a reconnect when no wallet is connected', async () => {
+ wallet.account = { status: 'disconnected', chainId: undefined }
+ const { avalancheAdapter } = await adapters()
+ const out = await avalancheAdapter.signPrepared?.(payload())
+ expect(out).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(wallet.calls).toEqual([])
+ })
+
+ const declined = new TransactionExecutionError(
+ new UserRejectedRequestError(new Error('nope')) as never,
+ { account: { address: ACCOUNT }, docsPath: '', chain: undefined } as never
+ )
+
+ it.each<[string, unknown]>([
+ ['viem’s rejection, however deep', declined],
+ ['a wallet’s own wording', new Error('MetaMask Tx Signature: User denied transaction')],
+ ['a closed popup', new Error('Popup closed')],
+ ])('reads %s as a decline', async (_name, thrown) => {
+ wallet.sendError = thrown
+ const { avalancheAdapter } = await adapters()
+ await expect(avalancheAdapter.signPrepared?.(payload())).resolves.toEqual({
+ ok: false,
+ reason: 'rejected',
+ })
+ })
+
+ it('reads a declined network switch as a decline, and sends nothing', async () => {
+ wallet.account = { status: 'connected', chainId: 1 }
+ wallet.switchError = new Error('User rejected the request.')
+ const { avalancheAdapter } = await adapters()
+ await expect(avalancheAdapter.signPrepared?.(payload())).resolves.toEqual({
+ ok: false,
+ reason: 'rejected',
+ })
+ expect(wallet.calls.map((c) => c[0])).toEqual(['switch'])
+ })
+
+ it('keeps the detail of any other wallet failure', async () => {
+ wallet.sendError = new Error('insufficient funds for gas')
+ const { avalancheAdapter } = await adapters()
+ await expect(avalancheAdapter.signPrepared?.(payload())).resolves.toEqual({
+ ok: false,
+ reason: 'wallet_error',
+ detail: 'insufficient funds for gas',
+ })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-deposit-balance.test.ts b/apps/dapp/lib/__tests__/weave-deposit-balance.test.ts
new file mode 100644
index 00000000..3eaebb0f
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-deposit-balance.test.ts
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ viewCall: vi.fn(),
+ balanceOf: vi.fn(),
+ getBalance: vi.fn(),
+ venueFetch: vi.fn(),
+}))
+
+vi.mock('../chains/near/rpc', async (original) => ({
+ ...(await original()),
+ viewCall: mocks.viewCall,
+}))
+vi.mock('../swap/delivered-balance', () => ({ balanceOf: mocks.balanceOf }))
+vi.mock('../chains/sui/client', async (original) => ({
+ ...(await original()),
+ suiClient: () => ({ core: { getBalance: mocks.getBalance } }),
+}))
+vi.mock('../chains/solana/http', async (original) => ({
+ ...(await original()),
+ venueFetch: mocks.venueFetch,
+}))
+
+import { USDC_NEAR } from '../chains/near/tokens'
+import { createAvalancheDeposit, type AvalancheClient } from '../weave/deposit/avalanche'
+import { nearDeposit } from '../weave/deposit/near'
+import { solanaDeposit } from '../weave/deposit/solana'
+import { stellarDeposit } from '../weave/deposit/stellar'
+import { suiDeposit } from '../weave/deposit/sui'
+
+const SOLANA_WALLET = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('usdcBalance', () => {
+ it('reads NEAR from the USDC contract', async () => {
+ mocks.viewCall.mockResolvedValue('2500000')
+ expect(await nearDeposit.usdcBalance('alice.near')).toBe(BigInt(2500000))
+ expect(mocks.viewCall).toHaveBeenCalledWith(USDC_NEAR, 'ft_balance_of', {
+ account_id: 'alice.near',
+ })
+ })
+
+ it('reads Stellar, and an account with no USDC holds none', async () => {
+ mocks.balanceOf.mockResolvedValueOnce(BigInt(30000000))
+ expect(await stellarDeposit.usdcBalance('GABC')).toBe(BigInt(30000000))
+ mocks.balanceOf.mockResolvedValueOnce(undefined)
+ expect(await stellarDeposit.usdcBalance('GABC')).toBe(BigInt(0))
+ })
+
+ it('reads Sui', async () => {
+ mocks.getBalance.mockResolvedValue({ balance: { balance: '1234567' } })
+ expect(await suiDeposit.usdcBalance('0x1')).toBe(BigInt(1234567))
+ })
+
+ it('reads Solana from the wallet’s USDC account, without an indexed call', async () => {
+ mocks.venueFetch.mockResolvedValue({
+ status: 200,
+ body: {
+ result: {
+ value: { owner: 'x', data: { parsed: { info: { tokenAmount: { amount: '4200000' } } } } },
+ },
+ },
+ })
+ expect(await solanaDeposit.usdcBalance(SOLANA_WALLET)).toBe(BigInt(4200000))
+ // The public RPC refuses getTokenAccountBalance without a personal token.
+ const [, request] = mocks.venueFetch.mock.calls[0] as [string, { body: { method: string } }]
+ expect(request.body.method).toBe('getAccountInfo')
+ })
+
+ it('reads a Solana wallet with no USDC account as holding none', async () => {
+ mocks.venueFetch.mockResolvedValue({ status: 200, body: { result: { value: null } } })
+ expect(await solanaDeposit.usdcBalance(SOLANA_WALLET)).toBe(BigInt(0))
+ })
+
+ it('does not read a Solana failure as an empty wallet', async () => {
+ mocks.venueFetch.mockResolvedValue({ status: 200, body: { error: { message: 'busy' } } })
+ await expect(solanaDeposit.usdcBalance(SOLANA_WALLET)).rejects.toThrow()
+ mocks.venueFetch.mockResolvedValue({ status: 403, body: {} })
+ await expect(solanaDeposit.usdcBalance(SOLANA_WALLET)).rejects.toThrow()
+ })
+
+ it('reads Avalanche', async () => {
+ const usdcBalance = vi.fn(async () => BigInt(9000000))
+ const deposit = createAvalancheDeposit(() => ({ usdcBalance }) as unknown as AvalancheClient)
+ const account = '0x00000000000000000000000000000000000000aa'
+ expect(await deposit.usdcBalance(account)).toBe(BigInt(9000000))
+ expect(usdcBalance).toHaveBeenCalledWith(account)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-deposit-near.test.ts b/apps/dapp/lib/__tests__/weave-deposit-near.test.ts
new file mode 100644
index 00000000..15ac189b
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-deposit-near.test.ts
@@ -0,0 +1,352 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { isRegistered, waitForTransaction, type ExecutedCall } from '../chains/near/rpc'
+import { STORAGE_DEPOSIT_YOCTO, STORAGE_GAS, TRANSFER_GAS } from '../chains/near/safety'
+import { USDC_NEAR, USDT_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+import { ChainActionRefused, type NearTransaction, type SigningPayload } from '../chains/types'
+import { nearDeposit } from '../weave/deposit/near'
+import type { DepositExpectation } from '../weave/types'
+
+vi.mock('../chains/near/rpc', async (original) => ({
+ ...(await original()),
+ isRegistered: vi.fn(),
+ waitForTransaction: vi.fn(),
+}))
+
+const ACCOUNT = 'alice.near'
+const DEPOSIT = 'a'.repeat(64)
+const OTHER = 'b'.repeat(64)
+const HASH = '4'.repeat(44)
+const UNITS = '25500000'
+
+const EXPECTATION: DepositExpectation = {
+ account: ACCOUNT,
+ depositAddress: DEPOSIT,
+ memo: undefined,
+ amount: '25.5',
+}
+
+type Action = NearTransaction['actions'][number]
+
+function ftTransfer(args: Record = {}): Action {
+ return {
+ type: 'FunctionCall',
+ params: {
+ methodName: 'ft_transfer',
+ args: { receiver_id: DEPOSIT, amount: UNITS, ...args },
+ gas: TRANSFER_GAS.toString(),
+ deposit: '1',
+ },
+ }
+}
+
+function storageFor(account: string): Action {
+ return {
+ type: 'FunctionCall',
+ params: {
+ methodName: 'storage_deposit',
+ args: { account_id: account, registration_only: true },
+ gas: STORAGE_GAS.toString(),
+ deposit: STORAGE_DEPOSIT_YOCTO,
+ },
+ }
+}
+
+function payload(actions: Action[], receiverId = USDC_NEAR): SigningPayload {
+ return { family: 'near', transactions: [{ receiverId, actions }] }
+}
+
+function refused(candidate: SigningPayload, reason: RegExp, expectation = EXPECTATION): void {
+ let caught: unknown
+ try {
+ nearDeposit.assertSafe(candidate, expectation)
+ } catch (e) {
+ caught = e
+ }
+ expect(caught).toBeInstanceOf(ChainActionRefused)
+ expect((caught as Error).message).toMatch(reason)
+ expect((caught as Error).message).toMatch(/nothing was signed or sent\.$/i)
+}
+
+function landed(
+ fields: { signer?: string; receiver?: string; calls?: ExecutedCall[]; failed?: boolean } = {}
+): void {
+ vi.mocked(waitForTransaction).mockResolvedValue({
+ state: fields.failed === true ? 'failed' : 'success',
+ signer: fields.signer ?? ACCOUNT,
+ receiver: fields.receiver ?? USDC_NEAR,
+ calls: fields.calls ?? [
+ { method: 'ft_transfer', args: { receiver_id: DEPOSIT, amount: UNITS }, deposit: '1' },
+ ],
+ })
+}
+
+beforeEach(() => {
+ vi.mocked(isRegistered).mockReset()
+ vi.mocked(waitForTransaction).mockReset()
+})
+
+describe('what a deposit declares', () => {
+ it('is USDC with six places and no memo', () => {
+ expect(nearDeposit.chain).toBe('near')
+ expect(nearDeposit.usdcDecimals).toBe(6)
+ expect(nearDeposit.supportsMemo).toBe(false)
+ })
+
+ it('takes a NEAR account and a 64-hex deposit address', () => {
+ expect(nearDeposit.isAccount(ACCOUNT)).toBe(true)
+ expect(nearDeposit.isAccount('NOT VALID')).toBe(false)
+ expect(nearDeposit.isDepositAddress(DEPOSIT)).toBe(true)
+ expect(nearDeposit.isDepositAddress('deposit.near')).toBe(false)
+ expect(nearDeposit.isDepositAddress('A'.repeat(64))).toBe(false)
+ })
+})
+
+describe('assertSafe admits the one shape', () => {
+ it('a transfer alone', () => {
+ expect(() => nearDeposit.assertSafe(payload([ftTransfer()]), EXPECTATION)).not.toThrow()
+ })
+
+ it('storage for the deposit address, then the transfer', () => {
+ expect(() =>
+ nearDeposit.assertSafe(payload([storageFor(DEPOSIT), ftTransfer()]), EXPECTATION)
+ ).not.toThrow()
+ })
+})
+
+describe('assertSafe refuses', () => {
+ it('a different recipient, which the shape check alone would admit', () => {
+ refused(payload([ftTransfer({ receiver_id: OTHER })]), /deposit address/)
+ })
+
+ it('a named account as the recipient', () => {
+ refused(payload([ftTransfer({ receiver_id: 'bob.near' })]), /deposit address/)
+ })
+
+ it('a different amount, one unit either way, or written differently', () => {
+ refused(payload([ftTransfer({ amount: '25500001' })]), /amount/)
+ refused(payload([ftTransfer({ amount: '25499999' })]), /amount/)
+ refused(payload([ftTransfer({ amount: '025500000' })]), /amount/)
+ })
+
+ it('a different token', () => {
+ refused(payload([ftTransfer()], USDT_NEAR), /USDC/)
+ refused(payload([ftTransfer()], 'usdc.fake.near'), /not a token/)
+ })
+
+ it('wrapped NEAR, which is a shape the shared check would otherwise allow', () => {
+ const wrap: Action = {
+ type: 'FunctionCall',
+ params: { methodName: 'near_deposit', args: {}, gas: '1000', deposit: UNITS },
+ }
+ refused(payload([wrap, ftTransfer()], WRAP_NEAR), /USDC/)
+ })
+
+ it('an extra transfer', () => {
+ refused(payload([ftTransfer({ receiver_id: OTHER }), ftTransfer()]), /not allowed/)
+ refused(payload([ftTransfer(), ftTransfer()]), /not allowed/)
+ })
+
+ it('a second transaction', () => {
+ refused(
+ {
+ family: 'near',
+ transactions: [
+ { receiverId: USDC_NEAR, actions: [ftTransfer()] },
+ { receiverId: USDC_NEAR, actions: [ftTransfer()] },
+ ],
+ },
+ /exactly one/
+ )
+ })
+
+ it('ft_transfer_call, which runs code on the receiver', () => {
+ const call = ftTransfer()
+ refused(
+ payload([{ ...call, params: { ...call.params, methodName: 'ft_transfer_call' } }]),
+ /end in a token transfer/
+ )
+ })
+
+ it('an unknown method before the transfer', () => {
+ const call = ftTransfer()
+ refused(
+ payload([{ ...call, params: { ...call.params, methodName: 'set_owner' } }, ftTransfer()]),
+ /not allowed/
+ )
+ })
+
+ it('an extra argument on the transfer', () => {
+ refused(payload([ftTransfer({ memo: 'x' })]), /unexpected arguments/)
+ })
+
+ it('storage paid for anyone but the deposit address', () => {
+ refused(payload([storageFor(OTHER), ftTransfer()]), /unexpected account/)
+ refused(payload([storageFor(ACCOUNT), ftTransfer()]), /unexpected account/)
+ })
+
+ it('an order with a memo', () => {
+ refused(payload([ftTransfer()]), /memo/, { ...EXPECTATION, memo: 'x' })
+ })
+
+ it('an order with no valid signer account', () => {
+ refused(payload([ftTransfer()]), /not a NEAR account/, { ...EXPECTATION, account: 'NOPE!' })
+ })
+
+ it('an order whose amount has too many places', () => {
+ refused(payload([ftTransfer()]), /decimal/, { ...EXPECTATION, amount: '25.5000001' })
+ })
+
+ it('a payload for another chain', () => {
+ refused({ family: 'solana', transaction: 'AA==' }, /not a NEAR transaction/)
+ })
+})
+
+describe('build', () => {
+ it('adds storage for an unregistered deposit address, then the transfer', async () => {
+ vi.mocked(isRegistered).mockResolvedValue(false)
+ const built = await nearDeposit.build(EXPECTATION)
+ expect(built).toEqual(payload([storageFor(DEPOSIT), ftTransfer()]))
+ expect(isRegistered).toHaveBeenCalledWith(USDC_NEAR, DEPOSIT)
+ })
+
+ it('leaves storage out for a registered one', async () => {
+ vi.mocked(isRegistered).mockResolvedValue(true)
+ expect(await nearDeposit.build(EXPECTATION)).toEqual(payload([ftTransfer()]))
+ })
+
+ it('refuses when the network cannot say whether it is registered', async () => {
+ vi.mocked(isRegistered).mockRejectedValue(new Error('down'))
+ await expect(nearDeposit.build(EXPECTATION)).rejects.toBeInstanceOf(ChainActionRefused)
+ })
+
+ it.each([
+ ['a memo', { memo: 'x' }],
+ ['a bad account', { account: 'NOPE!' }],
+ ['a named deposit address', { depositAddress: 'bob.near' }],
+ ['the account as its own deposit', { account: DEPOSIT }],
+ ['too many places', { amount: '1.0000001' }],
+ ['a zero amount', { amount: '0' }],
+ ])('refuses %s before asking the network', async (_name, change) => {
+ const attempt = nearDeposit.build({ ...EXPECTATION, ...change })
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/nothing was signed or sent\.$/i)
+ expect(isRegistered).not.toHaveBeenCalled()
+ })
+})
+
+describe('submit', () => {
+ const signed = { family: 'near' as const, hashes: [HASH] }
+
+ it('confirms the deposit on chain', async () => {
+ landed()
+ const result = await nearDeposit.submit(signed, EXPECTATION)
+ expect(result).toEqual({
+ ok: true,
+ hash: HASH,
+ explorerUrl: `https://nearblocks.io/txns/${HASH}`,
+ })
+ expect(waitForTransaction).toHaveBeenCalledWith(HASH, ACCOUNT, 15_000)
+ })
+
+ it('follows the last hash the wallet returned', async () => {
+ landed()
+ const last = '5'.repeat(44)
+ await nearDeposit.submit({ family: 'near', hashes: [HASH, last] }, EXPECTATION)
+ expect(waitForTransaction).toHaveBeenCalledWith(last, ACCOUNT, 15_000)
+ })
+
+ it('confirms a transfer that followed a storage payment', async () => {
+ landed({
+ calls: [
+ {
+ method: 'storage_deposit',
+ args: { account_id: DEPOSIT, registration_only: true },
+ deposit: STORAGE_DEPOSIT_YOCTO,
+ },
+ { method: 'ft_transfer', args: { receiver_id: DEPOSIT, amount: UNITS }, deposit: '1' },
+ ],
+ })
+ expect((await nearDeposit.submit(signed, EXPECTATION)).ok).toBe(true)
+ })
+
+ it.each([
+ ['another signer', { signer: 'mallory.near' }],
+ ['another token', { receiver: USDT_NEAR }],
+ [
+ 'another recipient',
+ {
+ calls: [
+ { method: 'ft_transfer', args: { receiver_id: OTHER, amount: UNITS }, deposit: '1' },
+ ],
+ },
+ ],
+ [
+ 'another amount',
+ {
+ calls: [
+ { method: 'ft_transfer', args: { receiver_id: DEPOSIT, amount: '1' }, deposit: '1' },
+ ],
+ },
+ ],
+ [
+ 'no amount at all',
+ { calls: [{ method: 'ft_transfer', args: { receiver_id: DEPOSIT }, deposit: '1' }] },
+ ],
+ [
+ 'another method',
+ {
+ calls: [
+ {
+ method: 'ft_transfer_call',
+ args: { receiver_id: DEPOSIT, amount: UNITS },
+ deposit: '1',
+ },
+ ],
+ },
+ ],
+ ['no calls', { calls: [] }],
+ ])('says %s is not this order’s deposit', async (_name, fields) => {
+ landed(fields)
+ const result = await nearDeposit.submit(signed, EXPECTATION)
+ expect(result.ok).toBe(false)
+ expect(result).not.toHaveProperty('pending')
+ if (!result.ok) expect(result.error).toMatch(/not this order/)
+ })
+
+ it('reports a failed transaction plainly', async () => {
+ landed({ failed: true })
+ const result = await nearDeposit.submit(signed, EXPECTATION)
+ expect(result.ok).toBe(false)
+ expect(result).not.toHaveProperty('pending')
+ if (!result.ok) expect(result.error).toMatch(/did not leave.*gas/)
+ })
+
+ it('reports an unseen transaction as pending, with the hash', async () => {
+ vi.mocked(waitForTransaction).mockResolvedValue({ state: 'unknown' })
+ const result = await nearDeposit.submit(signed, EXPECTATION)
+ expect(result).toMatchObject({ ok: false, pending: true })
+ if (!result.ok) {
+ expect(result.error).toContain(HASH)
+ expect(result.error).toMatch(/don.t send it again/i)
+ }
+ })
+
+ it('treats a missing or malformed hash as may-have-landed, since the wallet sends for itself', async () => {
+ for (const hashes of [[], ['not-a-hash']]) {
+ const result = await nearDeposit.submit({ family: 'near', hashes }, EXPECTATION)
+ expect(result).toMatchObject({ ok: false, pending: true })
+ }
+ expect(waitForTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses another family without looking anything up', async () => {
+ const result = await nearDeposit.submit(
+ { family: 'solana', signedTransaction: 'AA==' },
+ EXPECTATION
+ )
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ expect(waitForTransaction).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts
new file mode 100644
index 00000000..bb9274e5
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts
@@ -0,0 +1,694 @@
+import {
+ AccountRole,
+ appendTransactionMessageInstructions,
+ compileTransaction,
+ createTransactionMessage,
+ getBase64EncodedWireTransaction,
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getTransactionDecoder,
+ getU32Encoder,
+ getU64Decoder,
+ getU64Encoder,
+ pipe,
+ setTransactionMessageFeePayer,
+ setTransactionMessageLifetimeUsingBlockhash,
+ type Address,
+ type Blockhash,
+} 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 { SOLANA_TOKENS } from '../chains/solana/tokens'
+import { ChainActionRefused } from '../chains/types'
+import { solanaDeposit } from '../weave/deposit/solana'
+import type { DepositExpectation } from '../weave/types'
+
+// The real submit, without the minute of polling it would otherwise wait through.
+vi.mock('../chains/solana/submit', async (original) => {
+ const actual = await original()
+ return {
+ ...actual,
+ createSolanaSubmit: () => actual.createSolanaSubmit({ pollMs: 0, maxPolls: 1, env: {} }),
+ }
+})
+
+const ACCOUNT = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'
+const DEPOSIT = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU'
+const OTHER = 'DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy'
+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 COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111'
+const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
+const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'
+const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'
+
+const BASE_UNITS = BigInt(25_500_000)
+const EXPECTATION: DepositExpectation = {
+ account: ACCOUNT,
+ depositAddress: DEPOSIT,
+ memo: undefined,
+ amount: '25.5',
+}
+
+interface Instruction {
+ programAddress: string
+ accounts: readonly { address: string; role: AccountRole }[]
+ data: Uint8Array
+}
+
+function compile(
+ instructions: readonly Instruction[],
+ options: { feePayer?: string; version?: 0 | 'legacy' } = {}
+): string {
+ const message = pipe(
+ createTransactionMessage({ version: options.version ?? 0 }),
+ (m) => setTransactionMessageFeePayer((options.feePayer ?? ACCOUNT) as Address, m),
+ (m) =>
+ setTransactionMessageLifetimeUsingBlockhash(
+ { blockhash: BLOCKHASH as Blockhash, lastValidBlockHeight: BigInt(1) },
+ m
+ ),
+ (m) => appendTransactionMessageInstructions(instructions as never, m)
+ )
+ return getBase64EncodedWireTransaction(compileTransaction(message as never))
+}
+
+function transferData(amount: bigint, tag = 12, decimals = 6): Uint8Array {
+ return Uint8Array.from([tag, ...getU64Encoder().encode(amount), decimals])
+}
+
+function transfer(
+ fields: {
+ source?: string
+ mint?: string
+ destination?: string
+ owner?: string
+ amount?: bigint
+ tag?: number
+ decimals?: number
+ extraAccount?: string
+ } = {}
+): Instruction {
+ const accounts = [
+ {
+ address: fields.source ?? associatedTokenAccount(ACCOUNT, USDC),
+ role: AccountRole.WRITABLE,
+ },
+ { address: fields.mint ?? USDC, role: AccountRole.READONLY },
+ {
+ address: fields.destination ?? associatedTokenAccount(DEPOSIT, USDC),
+ role: AccountRole.WRITABLE,
+ },
+ { address: fields.owner ?? ACCOUNT, role: AccountRole.READONLY_SIGNER },
+ ]
+ if (fields.extraAccount !== undefined) {
+ accounts.push({ address: fields.extraAccount, role: AccountRole.READONLY })
+ }
+ return {
+ programAddress: TOKEN_PROGRAM,
+ accounts,
+ data: transferData(fields.amount ?? BASE_UNITS, fields.tag, fields.decimals),
+ }
+}
+
+function createAta(
+ fields: {
+ payer?: string
+ ata?: string
+ owner?: string
+ mint?: string
+ system?: string
+ token?: string
+ data?: readonly number[]
+ } = {}
+): Instruction {
+ return {
+ programAddress: ASSOCIATED_TOKEN_PROGRAM,
+ accounts: [
+ { address: fields.payer ?? ACCOUNT, role: AccountRole.WRITABLE_SIGNER },
+ { address: fields.ata ?? associatedTokenAccount(DEPOSIT, USDC), role: AccountRole.WRITABLE },
+ { address: fields.owner ?? DEPOSIT, role: AccountRole.READONLY },
+ { address: fields.mint ?? USDC, role: AccountRole.READONLY },
+ { address: fields.system ?? SYSTEM_PROGRAM, role: AccountRole.READONLY },
+ { address: fields.token ?? TOKEN_PROGRAM, role: AccountRole.READONLY },
+ ],
+ data: Uint8Array.from(fields.data ?? [1]),
+ }
+}
+
+function setLimit(units: number): Instruction {
+ return {
+ programAddress: COMPUTE_BUDGET,
+ accounts: [],
+ data: Uint8Array.from([2, ...getU32Encoder().encode(units)]),
+ }
+}
+
+function setPrice(microLamports: bigint): Instruction {
+ return {
+ programAddress: COMPUTE_BUDGET,
+ accounts: [],
+ data: Uint8Array.from([3, ...getU64Encoder().encode(microLamports)]),
+ }
+}
+
+type Message = ReturnType['decode']>
+
+function openMessage(base64: string): Message {
+ const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64'))
+ return getCompiledTransactionMessageDecoder().decode(messageBytes)
+}
+
+function closeMessage(message: unknown): string {
+ const bytes = getCompiledTransactionMessageEncoder().encode(message as never)
+ return Buffer.concat([Buffer.from([1]), Buffer.alloc(64), Buffer.from(bytes)]).toString('base64')
+}
+
+function check(transaction: string, expectation = EXPECTATION): void {
+ solanaDeposit.assertSafe({ family: 'solana', transaction }, expectation)
+}
+
+function refused(transaction: string, reason: RegExp): void {
+ let caught: unknown
+ try {
+ check(transaction)
+ } catch (e) {
+ caught = e
+ }
+ expect(caught).toBeInstanceOf(ChainActionRefused)
+ expect((caught as Error).message).toMatch(reason)
+ expect((caught as Error).message).toMatch(/nothing was signed or sent\.$/i)
+}
+
+interface RpcAnswers {
+ account?: unknown
+ blockhash?: unknown
+ send?: () => unknown
+ statuses?: unknown
+ isValid?: boolean
+}
+
+function stubRpc(answers: RpcAnswers = {}): ReturnType {
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
+ const { method } = JSON.parse(String(init?.body)) as { method: string }
+ const replies: Record = {
+ getAccountInfo: answers.account ?? { result: { context: { slot: 1 }, value: null } },
+ getLatestBlockhash: answers.blockhash ?? {
+ result: {
+ context: { slot: 1 },
+ value: { blockhash: BLOCKHASH, lastValidBlockHeight: 100 },
+ },
+ },
+ sendTransaction: answers.send?.() ?? { result: 'sent' },
+ getSignatureStatuses: answers.statuses ?? {
+ result: { value: [{ confirmationStatus: 'confirmed', err: null }] },
+ },
+ isBlockhashValid: { result: { value: answers.isValid ?? true } },
+ }
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, ...(replies[method] as object) }))
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ return fetchMock
+}
+
+function rpcMethods(fetchMock: ReturnType): string[] {
+ return fetchMock.mock.calls.map(
+ ([, init]) => (JSON.parse(String((init as RequestInit).body)) as { method: string }).method
+ )
+}
+
+/** Fills the fee payer's signature slot, standing in for a wallet. No key is involved. */
+function signed(base64: string): string {
+ const bytes = Buffer.from(base64, 'base64')
+ bytes.fill(7, 1, 65)
+ return bytes.toString('base64')
+}
+
+beforeEach(() => {
+ vi.stubEnv('SOLANA_RPC_URL', 'https://rpc.example.test')
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
+})
+
+describe('what a deposit declares', () => {
+ it('is USDC with six places and no memo', () => {
+ expect(solanaDeposit.chain).toBe('solana')
+ expect(solanaDeposit.usdcDecimals).toBe(6)
+ expect(solanaDeposit.supportsMemo).toBe(false)
+ })
+
+ it('takes Solana addresses only', () => {
+ expect(solanaDeposit.isAccount(ACCOUNT)).toBe(true)
+ expect(solanaDeposit.isDepositAddress(DEPOSIT)).toBe(true)
+ expect(solanaDeposit.isAccount('not-an-address')).toBe(false)
+ expect(solanaDeposit.isDepositAddress('0xabc')).toBe(false)
+ })
+})
+
+describe('assertSafe admits the one shape', () => {
+ it.each([
+ ['a bare transfer', [transfer()], 0],
+ ['a legacy message', [transfer()], 'legacy'],
+ ['with an idempotent account creation', [createAta(), transfer()], 0],
+ [
+ 'with both compute budget settings',
+ [setLimit(100_000), setPrice(BigInt(1_000)), createAta(), transfer()],
+ 0,
+ ],
+ ] as const)('%s', (_name, instructions, version) => {
+ expect(() => check(compile(instructions, { version }))).not.toThrow()
+ })
+})
+
+describe('assertSafe refuses', () => {
+ it('a different recipient', () => {
+ refused(
+ compile([transfer({ destination: associatedTokenAccount(OTHER, USDC) })]),
+ /deposit address/
+ )
+ })
+
+ it('the deposit wallet itself as the destination, which is not its token account', () => {
+ refused(compile([transfer({ destination: DEPOSIT })]), /deposit address/)
+ })
+
+ it('a different amount, one unit either way', () => {
+ refused(compile([transfer({ amount: BASE_UNITS + BigInt(1) })]), /amount/)
+ refused(compile([transfer({ amount: BASE_UNITS - BigInt(1) })]), /amount/)
+ })
+
+ it('a different token', () => {
+ refused(
+ compile([
+ transfer({
+ mint: USDT,
+ source: associatedTokenAccount(ACCOUNT, USDT),
+ destination: associatedTokenAccount(DEPOSIT, USDT),
+ }),
+ ]),
+ /USDC/
+ )
+ })
+
+ it('the right accounts with a different mint named', () => {
+ refused(compile([transfer({ mint: USDT })]), /USDC/)
+ })
+
+ it('wrong decimals', () => {
+ refused(compile([transfer({ decimals: 9 })]), /USDC/)
+ })
+
+ it('a plain Transfer, which does not name a mint', () => {
+ refused(compile([transfer({ tag: 3 })]), /token/i)
+ })
+
+ it('an approve hidden under the Token Program', () => {
+ refused(compile([transfer(), { ...transfer(), data: Uint8Array.from([4, 0]) }]), /token/i)
+ })
+
+ it('an extra transfer', () => {
+ refused(compile([transfer(), transfer()]), /one/)
+ refused(
+ compile([transfer({ amount: BigInt(1), destination: OTHER }), transfer()]),
+ /deposit address|one/
+ )
+ })
+
+ it('a SOL transfer alongside', () => {
+ const sol: Instruction = {
+ programAddress: SYSTEM_PROGRAM,
+ accounts: [
+ { address: ACCOUNT, role: AccountRole.WRITABLE_SIGNER },
+ { address: OTHER, role: AccountRole.WRITABLE },
+ ],
+ data: Uint8Array.from([
+ ...getU32Encoder().encode(2),
+ ...getU64Encoder().encode(BigInt(1_000)),
+ ]),
+ }
+ refused(compile([sol, transfer()]), /program/)
+ })
+
+ it('an unknown program', () => {
+ const unknown: Instruction = {
+ programAddress: OTHER,
+ accounts: [],
+ data: Uint8Array.from([1]),
+ }
+ refused(compile([unknown, transfer()]), /program/)
+ })
+
+ it('a memo instruction', () => {
+ const memo: Instruction = {
+ programAddress: MEMO_PROGRAM,
+ accounts: [],
+ data: Uint8Array.from([104, 105]),
+ }
+ refused(compile([transfer(), memo]), /program/)
+ })
+
+ it('a different fee payer', () => {
+ refused(compile([transfer()], { feePayer: OTHER }), /fee/)
+ })
+
+ it('a second signer', () => {
+ const second = transfer({
+ extraAccount: OTHER,
+ })
+ const accounts = second.accounts.map((a) =>
+ a.address === OTHER ? { ...a, role: AccountRole.READONLY_SIGNER } : a
+ )
+ refused(compile([{ ...second, accounts }]), /signature/)
+ })
+
+ it('a transfer signed by someone else, from their account', () => {
+ refused(compile([transfer({ owner: OTHER })]), /signature|your wallet/)
+ refused(
+ compile([transfer({ source: associatedTokenAccount(OTHER, USDC) })]),
+ /your USDC account/
+ )
+ })
+
+ it('a transfer carrying extra accounts', () => {
+ refused(compile([transfer({ extraAccount: OTHER })]), /token/i)
+ })
+
+ it('an account created for someone other than the deposit address', () => {
+ refused(
+ compile([createAta({ owner: OTHER, ata: associatedTokenAccount(OTHER, USDC) }), transfer()]),
+ /account creation/
+ )
+ })
+
+ it('an account created for a different mint', () => {
+ refused(
+ compile([createAta({ mint: USDT, ata: associatedTokenAccount(DEPOSIT, USDT) }), transfer()]),
+ /account creation/
+ )
+ })
+
+ it('an account creation someone else pays for', () => {
+ const creation = createAta({ payer: OTHER })
+ refused(compile([creation, transfer()]), /signature|account creation/)
+ })
+
+ it('an account creation under another token program', () => {
+ refused(compile([createAta({ token: TOKEN_2022 }), transfer()]), /account creation/)
+ })
+
+ it('a non-idempotent creation, which fails if the account exists', () => {
+ refused(compile([createAta({ data: [0] }), transfer()]), /account creation/)
+ refused(compile([createAta({ data: [] }), transfer()]), /account creation/)
+ })
+
+ it('two account creations', () => {
+ refused(compile([createAta(), createAta(), transfer()]), /one/)
+ })
+
+ it('steps out of order', () => {
+ refused(compile([transfer(), createAta()]), /one/)
+ refused(compile([createAta(), setPrice(BigInt(1)), transfer()]), /one/)
+ })
+
+ it('no transfer at all', () => {
+ refused(compile([createAta()]), /one/)
+ })
+
+ it('a compute budget option outside set-limit and set-price', () => {
+ const heap: Instruction = {
+ programAddress: COMPUTE_BUDGET,
+ accounts: [],
+ data: Uint8Array.from([1, ...getU32Encoder().encode(256_000)]),
+ }
+ const loaded: Instruction = {
+ programAddress: COMPUTE_BUDGET,
+ accounts: [],
+ data: Uint8Array.from([4, ...getU32Encoder().encode(32_000)]),
+ }
+ refused(compile([heap, transfer()]), /compute budget/)
+ refused(compile([loaded, transfer()]), /compute budget/)
+ })
+
+ it('a compute budget setting given twice', () => {
+ refused(compile([setPrice(BigInt(1)), setPrice(BigInt(2)), transfer()]), /compute budget/)
+ })
+
+ it('a large priority fee, with or without a stated limit', () => {
+ refused(compile([setLimit(1_000_000), setPrice(BigInt(5_000_001)), transfer()]), /priority fee/)
+ refused(compile([setPrice(BigInt(1_000_000_000)), transfer()]), /priority fee/)
+ })
+
+ it('a priority fee just inside the bound passes', () => {
+ expect(() =>
+ check(compile([setLimit(1_000_000), setPrice(BigInt(5_000_000)), transfer()]))
+ ).not.toThrow()
+ })
+
+ it('a lookup table attached and unused', () => {
+ const message = openMessage(compile([transfer()]))
+ refused(
+ closeMessage({
+ ...message,
+ addressTableLookups: [
+ { lookupTableAddress: OTHER, writableIndexes: [0], readonlyIndexes: [] },
+ ],
+ }),
+ /lookup table/
+ )
+ })
+
+ it('a destination hidden behind a lookup table', () => {
+ const message = openMessage(compile([transfer()])) as unknown as {
+ staticAccounts: string[]
+ instructions: { accountIndices: number[] }[]
+ }
+ const instructions = message.instructions.map((instruction) => ({
+ ...instruction,
+ accountIndices: instruction.accountIndices.map((index, position) =>
+ position === 2 ? message.staticAccounts.length : index
+ ),
+ }))
+ refused(
+ closeMessage({
+ ...message,
+ instructions,
+ addressTableLookups: [
+ { lookupTableAddress: OTHER, writableIndexes: [0], readonlyIndexes: [] },
+ ],
+ }),
+ /lookup table/
+ )
+ })
+
+ it('bytes that are not a transaction', () => {
+ refused('!!!', /could not be read/)
+ refused(Buffer.from('hello').toString('base64'), /could not be read/)
+ })
+
+ it('bytes hidden after the message', () => {
+ const padded = Buffer.concat([
+ Buffer.from(compile([transfer()]), 'base64'),
+ Buffer.from([1, 2, 3]),
+ ])
+ refused(padded.toString('base64'), /can.t check|could not be read/)
+ })
+
+ it('a payload for another chain', () => {
+ expect(() =>
+ solanaDeposit.assertSafe({ family: 'sui', transaction: compile([transfer()]) }, EXPECTATION)
+ ).toThrow(ChainActionRefused)
+ })
+
+ it('an order with a memo', () => {
+ expect(() => check(compile([transfer()]), { ...EXPECTATION, memo: 'pay-ref' })).toThrow(/memo/)
+ })
+
+ it('an order whose deposit address is the account itself', () => {
+ expect(() => check(compile([transfer()]), { ...EXPECTATION, depositAddress: ACCOUNT })).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('an order amount with too many places, even if the transaction rounds to it', () => {
+ expect(() => check(compile([transfer()]), { ...EXPECTATION, amount: '25.5000001' })).toThrow(
+ /nothing was signed or sent/i
+ )
+ })
+})
+
+describe('build', () => {
+ it('writes a transaction its own check admits, with the account paying', async () => {
+ stubRpc()
+ const payload = await solanaDeposit.build(EXPECTATION)
+ expect(payload.family).toBe('solana')
+ if (payload.family !== 'solana') return
+
+ expect(() => solanaDeposit.assertSafe(payload, EXPECTATION)).not.toThrow()
+ const decoded = decodeSolanaTransaction(payload.transaction)
+ expect(decoded.feePayer).toBe(ACCOUNT)
+ expect(decoded.blockhash).toBe(BLOCKHASH)
+ expect(decoded.signerCount).toBe(1)
+
+ const last = decoded.instructions[decoded.instructions.length - 1]
+ expect(last?.data[0]).toBe(12)
+ expect(getU64Decoder().decode(last?.data as Uint8Array, 1)).toBe(BASE_UNITS)
+ })
+
+ it('creates the deposit address its USDC account', async () => {
+ stubRpc()
+ const payload = await solanaDeposit.build(EXPECTATION)
+ if (payload.family !== 'solana') throw new Error('wrong family')
+ const decoded = decodeSolanaTransaction(payload.transaction)
+ const programs = decoded.instructions.map((i) => decoded.staticAccounts[i.programIndex])
+ expect(programs).toContain(ASSOCIATED_TOKEN_PROGRAM)
+ expect(decoded.staticAccounts).toContain(associatedTokenAccount(DEPOSIT, USDC))
+ })
+
+ it('accepts a wallet that already exists under the System Program', async () => {
+ stubRpc({ account: { result: { value: { owner: SYSTEM_PROGRAM, lamports: 1 } } } })
+ await expect(solanaDeposit.build(EXPECTATION)).resolves.toMatchObject({ family: 'solana' })
+ })
+
+ it.each([
+ ['the Token Program', TOKEN_PROGRAM],
+ ['Token-2022', TOKEN_2022],
+ ])('refuses a deposit address owned by %s, where USDC would be stuck', async (_name, owner) => {
+ stubRpc({ account: { result: { value: { owner, lamports: 1 } } } })
+ await expect(solanaDeposit.build(EXPECTATION)).rejects.toThrow(/token account/)
+ await expect(solanaDeposit.build(EXPECTATION)).rejects.toBeInstanceOf(ChainActionRefused)
+ })
+
+ it('refuses when the account cannot be read, rather than assuming it is missing', async () => {
+ stubRpc({ account: { error: { code: -32005, message: 'behind' } } })
+ await expect(solanaDeposit.build(EXPECTATION)).rejects.toBeInstanceOf(ChainActionRefused)
+
+ stubRpc({ account: { result: {} } })
+ await expect(solanaDeposit.build(EXPECTATION)).rejects.toBeInstanceOf(ChainActionRefused)
+ })
+
+ it('refuses when no blockhash comes back', async () => {
+ stubRpc({ blockhash: { error: { code: -32005, message: 'behind' } } })
+ await expect(solanaDeposit.build(EXPECTATION)).rejects.toBeInstanceOf(ChainActionRefused)
+ })
+
+ it('refuses a memo before asking the network anything', async () => {
+ const fetchMock = stubRpc()
+ await expect(solanaDeposit.build({ ...EXPECTATION, memo: 'x' })).rejects.toThrow(/memo/)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['account', { account: 'nope' }],
+ ['deposit address', { depositAddress: 'nope' }],
+ ['amount with too many places', { amount: '1.0000001' }],
+ ['zero amount', { amount: '0' }],
+ ['amount past what the token can hold', { amount: '18446744073710' }],
+ ['deposit address equal to the account', { depositAddress: ACCOUNT }],
+ ])('refuses a bad %s before asking the network', async (_name, change) => {
+ const fetchMock = stubRpc()
+ const attempt = solanaDeposit.build({ ...EXPECTATION, ...change })
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/nothing was signed or sent\.$/i)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('submit', () => {
+ const good = signed(compile([createAta(), transfer()]))
+
+ it('sends a signed deposit', async () => {
+ const fetchMock = stubRpc()
+ const result = await solanaDeposit.submit(
+ { family: 'solana', signedTransaction: good },
+ EXPECTATION
+ )
+ expect(result.ok).toBe(true)
+ expect(rpcMethods(fetchMock)).toContain('sendTransaction')
+ })
+
+ it('checks the signed bytes again and sends nothing if they changed', async () => {
+ const fetchMock = stubRpc()
+ const swapped = signed(
+ compile([transfer({ destination: associatedTokenAccount(OTHER, USDC) })])
+ )
+ await expect(
+ solanaDeposit.submit({ family: 'solana', signedTransaction: swapped }, EXPECTATION)
+ ).rejects.toBeInstanceOf(ChainActionRefused)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('refuses another family without sending', async () => {
+ const fetchMock = stubRpc()
+ const result = await solanaDeposit.submit({ family: 'near', hashes: ['x'] }, EXPECTATION)
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('does not send what the wallet left unsigned', async () => {
+ const fetchMock = stubRpc()
+ const result = await solanaDeposit.submit(
+ { family: 'solana', signedTransaction: compile([transfer()]) },
+ EXPECTATION
+ )
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ expect(rpcMethods(fetchMock)).not.toContain('sendTransaction')
+ })
+
+ it('reports an unconfirmed send as pending, so nobody pays twice', async () => {
+ stubRpc({ statuses: { result: { value: [null] } } })
+ const result = await solanaDeposit.submit(
+ { family: 'solana', signedTransaction: good },
+ EXPECTATION
+ )
+ expect(result).toMatchObject({ ok: false, pending: true })
+ if (!result.ok) expect(result.error).toMatch(/again/)
+ })
+
+ it('reports a send that broke mid-flight as pending', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('socket hang up')
+ })
+ )
+ const result = await solanaDeposit.submit(
+ { family: 'solana', signedTransaction: good },
+ EXPECTATION
+ )
+ expect(result).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('reports an expired transaction as a plain failure', async () => {
+ stubRpc({ statuses: { result: { value: [null] } }, isValid: false })
+ const result = await solanaDeposit.submit(
+ { family: 'solana', signedTransaction: good },
+ EXPECTATION
+ )
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it('reports a landed failure as a plain failure', async () => {
+ stubRpc({
+ statuses: { result: { value: [{ confirmationStatus: 'confirmed', err: { x: 1 } }] } },
+ })
+ const result = await solanaDeposit.submit(
+ { family: 'solana', signedTransaction: good },
+ EXPECTATION
+ )
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-deposit-stellar.test.ts b/apps/dapp/lib/__tests__/weave-deposit-stellar.test.ts
new file mode 100644
index 00000000..e59ec9c9
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-deposit-stellar.test.ts
@@ -0,0 +1,576 @@
+import { stellarNetwork } from '@intent/config'
+import {
+ Account,
+ Asset,
+ BASE_FEE,
+ Keypair,
+ Memo,
+ Operation,
+ StrKey,
+ TransactionBuilder,
+ type Transaction,
+} from '@stellar/stellar-sdk'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { ChainActionRefused, type SigningPayload } from '../chains/types'
+import { USDC } from '../swap/assets'
+import { stellarDeposit } from '../weave/deposit/stellar'
+import type { DepositExpectation } from '../weave/types'
+
+const kit = vi.hoisted(() => ({
+ signed: [] as { xdr: string; address: string | undefined }[],
+ signError: undefined as Error | undefined,
+ walletPassphrase: undefined as string | undefined,
+}))
+
+vi.mock('../stellar-kit', () => ({
+ ensureKit: () => undefined,
+ StellarWalletsKit: {
+ getNetwork: async () => ({ network: 'x', networkPassphrase: kit.walletPassphrase }),
+ signTransaction: async (xdr: string, opts?: { address?: string }) => {
+ if (kit.signError !== undefined) throw kit.signError
+ kit.signed.push({ xdr, address: opts?.address })
+ return { signedTxXdr: `signed:${xdr}` }
+ },
+ setNetwork: () => undefined,
+ selectedModule: { productIcon: '', productName: 'Test' },
+ },
+}))
+vi.mock('../api/auth', () => ({
+ clearSession: () => undefined,
+ ensureSession: async () => undefined,
+}))
+vi.mock('@creit.tech/stellar-wallets-kit/types', () => ({
+ Networks: { PUBLIC: 'PUBLIC', TESTNET: 'TESTNET' },
+}))
+
+const ACCOUNT = Keypair.random().publicKey()
+const DEPOSIT = Keypair.random().publicKey()
+const OTHER = Keypair.random().publicKey()
+const APP_USDC = new Asset(USDC.code, USDC.issuer as string)
+
+const plain: DepositExpectation = {
+ account: ACCOUNT,
+ depositAddress: DEPOSIT,
+ memo: undefined,
+ amount: '12.5',
+}
+const withMemo: DepositExpectation = { ...plain, memo: 'order-123' }
+
+interface Shape {
+ source?: string
+ destination?: string
+ amount?: string
+ asset?: Asset
+ memo?: Memo
+ opSource?: string
+ extra?: boolean
+}
+
+function tx(shape: Shape = {}): Transaction {
+ const builder = new TransactionBuilder(new Account(shape.source ?? ACCOUNT, '100'), {
+ fee: BASE_FEE,
+ networkPassphrase: stellarNetwork.networkPassphrase,
+ }).addOperation(
+ Operation.payment({
+ destination: shape.destination ?? DEPOSIT,
+ asset: shape.asset ?? APP_USDC,
+ amount: shape.amount ?? '12.5',
+ ...(shape.opSource !== undefined ? { source: shape.opSource } : {}),
+ })
+ )
+ if (shape.extra === true) {
+ builder.addOperation(Operation.payment({ destination: OTHER, asset: APP_USDC, amount: '1' }))
+ }
+ if (shape.memo !== undefined) builder.addMemo(shape.memo)
+ return builder.setTimeout(180).build()
+}
+
+function payload(shape: Shape = {}): SigningPayload {
+ return { family: 'stellar', xdr: tx(shape).toXDR(), account: ACCOUNT }
+}
+
+function signedXdr(shape: Shape = {}): string {
+ const t = tx(shape)
+ t.sign(Keypair.random())
+ return t.toXDR()
+}
+
+function feeBump(): string {
+ const inner = tx()
+ inner.sign(Keypair.random())
+ const outer = TransactionBuilder.buildFeeBumpTransaction(
+ Keypair.random(),
+ '200',
+ inner,
+ stellarNetwork.networkPassphrase
+ )
+ return outer.toXDR()
+}
+
+function refusalOf(run: () => unknown): Error {
+ try {
+ run()
+ } catch (e) {
+ return e as Error
+ }
+ throw new Error('expected a refusal, and nothing was refused')
+}
+
+function expectRefused(run: () => unknown, about: RegExp): void {
+ const e = refusalOf(run)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect(e.message).toMatch(about)
+ expect(e.message).toMatch(/Nothing was signed or sent\.$/)
+}
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status })
+}
+
+function stubFetch(answer: (url: string, init?: RequestInit) => Response | Promise) {
+ const fake = vi.fn(async (url: string | URL | Request, init?: RequestInit) =>
+ answer(String(url), init)
+ )
+ vi.stubGlobal('fetch', fake)
+ return fake
+}
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ kit.signed = []
+ kit.signError = undefined
+ kit.walletPassphrase = undefined
+})
+
+describe('stellarDeposit: what it says about itself', () => {
+ it('carries seven decimals and memos', () => {
+ expect(stellarDeposit.chain).toBe('stellar')
+ expect(stellarDeposit.usdcDecimals).toBe(7)
+ expect(stellarDeposit.supportsMemo).toBe(true)
+ })
+
+ it('takes only ordinary public keys as accounts and deposit addresses', () => {
+ const muxed = StrKey.encodeMed25519PublicKey(Buffer.alloc(40, 1))
+ const contract = StrKey.encodeContract(Buffer.alloc(32, 1))
+ for (const check of [stellarDeposit.isAccount, stellarDeposit.isDepositAddress]) {
+ expect(check(ACCOUNT)).toBe(true)
+ expect(check(muxed)).toBe(false)
+ expect(check(contract)).toBe(false)
+ expect(check('')).toBe(false)
+ expect(check('0x1111111111111111111111111111111111111111')).toBe(false)
+ }
+ })
+})
+
+describe('stellarDeposit.assertSafe', () => {
+ it('admits exactly the payment the order asked for', () => {
+ expect(() => stellarDeposit.assertSafe(payload(), plain)).not.toThrow()
+ })
+
+ it('reads 12.5 and 12.5000000 as one amount', () => {
+ expect(() => stellarDeposit.assertSafe(payload({ amount: '12.5000000' }), plain)).not.toThrow()
+ })
+
+ it('admits an operation sourced from the account itself', () => {
+ expect(() => stellarDeposit.assertSafe(payload({ opSource: ACCOUNT }), plain)).not.toThrow()
+ })
+
+ it('admits the exact memo the order needs', () => {
+ expect(() =>
+ stellarDeposit.assertSafe(payload({ memo: Memo.text('order-123') }), withMemo)
+ ).not.toThrow()
+ })
+
+ it('compares a memo beyond ascii by its bytes', () => {
+ const expectation = { ...plain, memo: 'héllo' }
+ expect(() =>
+ stellarDeposit.assertSafe(payload({ memo: Memo.text('héllo') }), expectation)
+ ).not.toThrow()
+ expectRefused(
+ () => stellarDeposit.assertSafe(payload({ memo: Memo.text('hello') }), expectation),
+ /memo/i
+ )
+ })
+
+ it.each<[string, Shape, RegExp]>([
+ ['another recipient', { destination: OTHER }, /deposit address/i],
+ ['more than the order', { amount: '12.5000001' }, /amount/i],
+ ['less than the order', { amount: '12.4999999' }, /amount/i],
+ ['the same code from another issuer', { asset: new Asset(USDC.code, OTHER) }, /USDC/],
+ [
+ 'another code from the same issuer',
+ { asset: new Asset('USDT', USDC.issuer as string) },
+ /USDC/,
+ ],
+ ['native XLM', { asset: Asset.native() }, /USDC/],
+ ['another transaction source', { source: OTHER }, /account/i],
+ ['an operation sourced elsewhere', { opSource: OTHER }, /account/i],
+ ['a second operation', { extra: true }, /one payment/i],
+ ])('refuses %s', (_name, shape, about) => {
+ expectRefused(() => stellarDeposit.assertSafe(payload(shape), plain), about)
+ })
+
+ it('refuses a fee bump', () => {
+ expectRefused(
+ () =>
+ stellarDeposit.assertSafe({ family: 'stellar', xdr: feeBump(), account: ACCOUNT }, plain),
+ /fee bump/i
+ )
+ })
+
+ it('refuses an operation that is not a payment', () => {
+ const builder = new TransactionBuilder(new Account(ACCOUNT, '100'), {
+ fee: BASE_FEE,
+ networkPassphrase: stellarNetwork.networkPassphrase,
+ })
+ .addOperation(
+ Operation.pathPaymentStrictSend({
+ sendAsset: APP_USDC,
+ sendAmount: '12.5',
+ destination: DEPOSIT,
+ destAsset: APP_USDC,
+ destMin: '12.5',
+ })
+ )
+ .setTimeout(180)
+ .build()
+ expectRefused(
+ () =>
+ stellarDeposit.assertSafe(
+ { family: 'stellar', xdr: builder.toXDR(), account: ACCOUNT },
+ plain
+ ),
+ /payment/i
+ )
+ })
+
+ it('refuses a payload that names another account', () => {
+ expectRefused(
+ () =>
+ stellarDeposit.assertSafe({ family: 'stellar', xdr: tx().toXDR(), account: OTHER }, plain),
+ /account/i
+ )
+ })
+
+ it('refuses another chain’s payload', () => {
+ expectRefused(
+ () => stellarDeposit.assertSafe({ family: 'solana', transaction: 'AAAA' }, plain),
+ /Stellar/
+ )
+ })
+
+ it('refuses bytes that are not a transaction', () => {
+ expectRefused(
+ () =>
+ stellarDeposit.assertSafe({ family: 'stellar', xdr: 'not-xdr', account: ACCOUNT }, plain),
+ /could not be read/i
+ )
+ })
+
+ describe('the memo, which can lose funds if it is wrong', () => {
+ it('refuses a missing memo when the order needs one', () => {
+ expectRefused(() => stellarDeposit.assertSafe(payload(), withMemo), /memo/i)
+ })
+
+ it('refuses a different memo', () => {
+ expectRefused(
+ () => stellarDeposit.assertSafe(payload({ memo: Memo.text('order-124') }), withMemo),
+ /memo/i
+ )
+ })
+
+ it('refuses a memo the order does not have', () => {
+ expectRefused(
+ () => stellarDeposit.assertSafe(payload({ memo: Memo.text('order-123') }), plain),
+ /memo/i
+ )
+ })
+
+ it('refuses the right digits in an id memo when the order says text', () => {
+ expectRefused(
+ () =>
+ stellarDeposit.assertSafe(payload({ memo: Memo.id('12345') }), {
+ ...plain,
+ memo: '12345',
+ }),
+ /memo/i
+ )
+ })
+
+ it('refuses a hash memo', () => {
+ expectRefused(
+ () =>
+ stellarDeposit.assertSafe(
+ payload({ memo: Memo.hash(Buffer.alloc(32, 1).toString('hex')) }),
+ withMemo
+ ),
+ /memo/i
+ )
+ })
+ })
+})
+
+describe('stellarDeposit.build', () => {
+ const horizon = (sequence = '4242') =>
+ stubFetch((url) =>
+ url.endsWith(`/accounts/${ACCOUNT}`) ? jsonResponse(200, { sequence }) : jsonResponse(500, {})
+ )
+
+ function read(built: SigningPayload): Transaction {
+ if (built.family !== 'stellar') throw new Error(`built ${built.family}`)
+ return TransactionBuilder.fromXDR(built.xdr, stellarNetwork.networkPassphrase) as Transaction
+ }
+
+ it('builds one USDC payment on the account’s next sequence, with no memo', async () => {
+ const fake = horizon()
+ const before = Math.floor(Date.now() / 1000)
+ const built = await stellarDeposit.build(plain)
+ expect(fake).toHaveBeenCalledWith(
+ `${stellarNetwork.horizonUrl}/accounts/${ACCOUNT}`,
+ expect.anything()
+ )
+ expect(built).toMatchObject({ family: 'stellar', account: ACCOUNT })
+ const t = read(built)
+ expect(t.source).toBe(ACCOUNT)
+ expect(t.sequence).toBe('4243')
+ expect(t.fee).toBe('100')
+ expect(t.memo.type).toBe('none')
+ expect(t.operations).toHaveLength(1)
+ expect(t.operations[0]).toMatchObject({
+ type: 'payment',
+ destination: DEPOSIT,
+ amount: '12.5000000',
+ })
+ const asset = (t.operations[0] as Operation.Payment).asset
+ expect(asset.getCode()).toBe(USDC.code)
+ expect(asset.getIssuer()).toBe(USDC.issuer)
+ const maxTime = Number(t.timeBounds?.maxTime)
+ expect(maxTime - before).toBeGreaterThanOrEqual(179)
+ expect(maxTime - before).toBeLessThanOrEqual(182)
+ })
+
+ it('carries the order’s memo as text', async () => {
+ horizon()
+ const t = read(await stellarDeposit.build(withMemo))
+ expect(t.memo.type).toBe('text')
+ expect(Buffer.from(t.memo.value as Uint8Array).toString('utf8')).toBe('order-123')
+ })
+
+ it('takes a memo of exactly 28 bytes', async () => {
+ horizon()
+ const memo = 'x'.repeat(28)
+ await expect(stellarDeposit.build({ ...plain, memo })).resolves.toMatchObject({
+ family: 'stellar',
+ })
+ })
+
+ it.each([
+ ['29 bytes', 'x'.repeat(29)],
+ ['30 bytes in 15 characters', 'é'.repeat(15)],
+ ['nothing at all', ''],
+ ])('refuses a memo of %s before asking the network', async (_name, memo) => {
+ const fake = horizon()
+ const e = await stellarDeposit.build({ ...plain, memo }).catch((err: Error) => err)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect((e as Error).message).toMatch(/memo/i)
+ expect((e as Error).message).toMatch(/Nothing was signed or sent\.$/)
+ expect(fake).not.toHaveBeenCalled()
+ })
+
+ it.each<[string, Partial, RegExp]>([
+ ['an account that is not one', { account: 'nope' }, /account/i],
+ ['a deposit address that is not one', { depositAddress: 'nope' }, /deposit address/i],
+ ['too many decimal places', { amount: '1.00000001' }, /decimal/i],
+ ['a zero amount', { amount: '0' }, /more than zero/i],
+ ['an amount that is not one', { amount: 'lots' }, /amount/i],
+ ])('refuses %s', async (_name, change, about) => {
+ const fake = horizon()
+ const e = await stellarDeposit.build({ ...plain, ...change }).catch((err: Error) => err)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect((e as Error).message).toMatch(about)
+ expect(fake).not.toHaveBeenCalled()
+ })
+
+ it('refuses an account that does not exist yet', async () => {
+ stubFetch(() => jsonResponse(404, {}))
+ const e = await stellarDeposit.build(plain).catch((err: Error) => err)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect((e as Error).message).toMatch(/not funded/i)
+ expect((e as Error).message).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses when the network cannot be read', async () => {
+ stubFetch(() => {
+ throw new Error('socket hang up')
+ })
+ const e = await stellarDeposit.build(plain).catch((err: Error) => err)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ expect((e as Error).message).toMatch(/Nothing was signed or sent\.$/)
+ })
+})
+
+describe('stellarDeposit.submit', () => {
+ it('sends the signed bytes unchanged and reports the hash', async () => {
+ const xdr = signedXdr()
+ const hash = Buffer.from(
+ TransactionBuilder.fromXDR(xdr, stellarNetwork.networkPassphrase).hash()
+ ).toString('hex')
+ const posted: string[] = []
+ stubFetch((url, init) => {
+ posted.push(`${url} ${String(init?.body)}`)
+ return jsonResponse(200, { hash, ledger: 7, successful: true })
+ })
+ const result = await stellarDeposit.submit({ family: 'stellar', signedXdr: xdr }, plain)
+ expect(result).toEqual({
+ ok: true,
+ hash,
+ explorerUrl: `${stellarNetwork.blockExplorerUrl}/tx/${hash}`,
+ })
+ expect(posted).toEqual([
+ `${stellarNetwork.horizonUrl}/transactions ${new URLSearchParams({ tx: xdr }).toString()}`,
+ ])
+ })
+
+ it('refuses another chain’s result and sends nothing', async () => {
+ const fake = stubFetch(() => jsonResponse(500, {}))
+ await expect(
+ stellarDeposit.submit({ family: 'evm', hash: `0x${'a'.repeat(64)}` }, plain)
+ ).rejects.toBeInstanceOf(ChainActionRefused)
+ expect(fake).not.toHaveBeenCalled()
+ })
+
+ it.each<[string, Shape, DepositExpectation]>([
+ ['a swapped recipient', { destination: OTHER }, plain],
+ ['a dropped memo', {}, withMemo],
+ ['an added memo', { memo: Memo.text('order-123') }, plain],
+ ])('refuses signed bytes with %s and sends nothing', async (_name, shape, expectation) => {
+ const fake = stubFetch(() => jsonResponse(200, { hash: 'x', successful: true }))
+ const e = await stellarDeposit
+ .submit({ family: 'stellar', signedXdr: signedXdr(shape) }, expectation)
+ .catch((err: Error) => err)
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ // The wallet has signed by now, so the sentence must not say otherwise.
+ expect((e as Error).message).not.toMatch(/Nothing was signed/)
+ expect((e as Error).message).toMatch(/not sent/i)
+ expect(fake).not.toHaveBeenCalled()
+ })
+
+ it('reports a definite refusal from the network as a plain failure', async () => {
+ stubFetch(() =>
+ jsonResponse(400, {
+ extras: { result_codes: { transaction: 'tx_failed', operations: ['op_underfunded'] } },
+ })
+ )
+ const result = await stellarDeposit.submit({ family: 'stellar', signedXdr: signedXdr() }, plain)
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it('reports a failure inside a ledger as a plain failure', async () => {
+ stubFetch(() =>
+ jsonResponse(200, {
+ hash: 'abc',
+ successful: false,
+ extras: { result_codes: { transaction: 'tx_failed', operations: ['op_no_trust'] } },
+ })
+ )
+ const result = await stellarDeposit.submit({ family: 'stellar', signedXdr: signedXdr() }, plain)
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it('treats a used-up sequence as pending, because an earlier send may have landed', async () => {
+ stubFetch(() => jsonResponse(400, { extras: { result_codes: { transaction: 'tx_bad_seq' } } }))
+ const result = await stellarDeposit.submit({ family: 'stellar', signedXdr: signedXdr() }, plain)
+ expect(result).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('treats no verdict from the network as pending, naming the hash', async () => {
+ vi.useFakeTimers()
+ const xdr = signedXdr()
+ const hash = Buffer.from(
+ TransactionBuilder.fromXDR(xdr, stellarNetwork.networkPassphrase).hash()
+ ).toString('hex')
+ stubFetch(() => {
+ throw new Error('socket hang up')
+ })
+ const running = stellarDeposit.submit({ family: 'stellar', signedXdr: xdr }, plain)
+ await vi.advanceTimersByTimeAsync(30_000)
+ const result = await running
+ expect(result).toMatchObject({ ok: false, pending: true })
+ if (result.ok) throw new Error('expected pending')
+ expect(result.error).toContain(hash)
+ expect(result.error).toMatch(/do not send/i)
+ })
+
+ it('counts a transfer that landed after Horizon timed out as done', async () => {
+ const xdr = signedXdr()
+ const hash = Buffer.from(
+ TransactionBuilder.fromXDR(xdr, stellarNetwork.networkPassphrase).hash()
+ ).toString('hex')
+ stubFetch((url) =>
+ url.endsWith(`/transactions/${hash}`)
+ ? jsonResponse(200, { hash, ledger: 9, successful: true })
+ : jsonResponse(504, {})
+ )
+ const result = await stellarDeposit.submit({ family: 'stellar', signedXdr: xdr }, plain)
+ expect(result).toMatchObject({ ok: true, hash })
+ })
+})
+
+describe('the Stellar adapter’s signPrepared', () => {
+ const PASSPHRASE = stellarNetwork.networkPassphrase
+ const WRONG =
+ PASSPHRASE === 'Test SDF Network ; September 2015'
+ ? 'Public Global Stellar Network ; September 2015'
+ : 'Test SDF Network ; September 2015'
+
+ async function adapter() {
+ const mod = await import('../adapters/stellar-adapter')
+ return mod.stellarAdapter
+ }
+
+ it('signs the payload for the account it names', async () => {
+ const a = await adapter()
+ kit.walletPassphrase = PASSPHRASE
+ const out = await a.signPrepared?.({ family: 'stellar', xdr: 'AAAA', account: ACCOUNT })
+ expect(out).toEqual({ ok: true, signed: { family: 'stellar', signedXdr: 'signed:AAAA' } })
+ expect(kit.signed).toEqual([{ xdr: 'AAAA', address: ACCOUNT }])
+ })
+
+ it('reports a decline as rejected', async () => {
+ const a = await adapter()
+ kit.walletPassphrase = PASSPHRASE
+ kit.signError = new Error('User declined the request')
+ await expect(
+ a.signPrepared?.({ family: 'stellar', xdr: 'AAAA', account: ACCOUNT })
+ ).resolves.toEqual({ ok: false, reason: 'rejected' })
+ })
+
+ it('keeps the detail when the wallet is on the wrong network', async () => {
+ const a = await adapter()
+ kit.walletPassphrase = WRONG
+ const out = await a.signPrepared?.({ family: 'stellar', xdr: 'AAAA', account: ACCOUNT })
+ expect(out).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(out).toHaveProperty('detail', expect.stringMatching(/.+/))
+ expect(kit.signed).toEqual([])
+ })
+
+ it('keeps the detail of any other wallet failure', async () => {
+ const a = await adapter()
+ kit.walletPassphrase = PASSPHRASE
+ kit.signError = new Error('The extension crashed')
+ await expect(
+ a.signPrepared?.({ family: 'stellar', xdr: 'AAAA', account: ACCOUNT })
+ ).resolves.toEqual({ ok: false, reason: 'wallet_error', detail: 'The extension crashed' })
+ })
+
+ it('refuses another family without calling the wallet', async () => {
+ const a = await adapter()
+ const out = await a.signPrepared?.({ family: 'solana', transaction: 'AAAA' })
+ expect(out).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(kit.signed).toEqual([])
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-deposit-sui.test.ts b/apps/dapp/lib/__tests__/weave-deposit-sui.test.ts
new file mode 100644
index 00000000..2dcf81fb
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-deposit-sui.test.ts
@@ -0,0 +1,589 @@
+import { Inputs, Transaction } from '@mysten/sui/transactions'
+import { normalizeSuiAddress, toBase64 } from '@mysten/sui/utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { SUI_TOKENS } from '../chains/sui/tokens'
+import { ChainActionRefused } from '../chains/types'
+import { suiDeposit } from '../weave/deposit/sui'
+import type { DepositExpectation } from '../weave/types'
+
+const core = vi.hoisted(() => ({
+ listCoins: vi.fn(),
+ getBalance: vi.fn(),
+ getObjects: vi.fn(),
+ executeTransaction: vi.fn(),
+ resolveTransactionPlugin: vi.fn(),
+}))
+
+vi.mock('../chains/sui/client', async (original) => ({
+ ...(await original()),
+ suiClient: () => ({ core }),
+}))
+
+const ME = normalizeSuiAddress('0xa1')
+const DEPOSIT = normalizeSuiAddress('0xd1')
+const THIEF = normalizeSuiAddress('0xdead')
+const USDC = SUI_TOKENS.find((t) => t.symbol === 'USDC')?.id as string
+const USDSUI = SUI_TOKENS.find((t) => t.symbol === 'USDsui')?.id as string
+const DIGEST = 'GKot5hBsd81kMupNCXHaqbhv3huEbxAFMLnpcX2hniwn'
+const UNITS = BigInt(25_500_000)
+
+const EXPECTATION: DepositExpectation = {
+ account: ME,
+ depositAddress: DEPOSIT,
+ memo: undefined,
+ amount: '25.5',
+}
+
+const COIN_A = normalizeSuiAddress('0xc1')
+const COIN_B = normalizeSuiAddress('0xc2')
+const GAS_COIN = normalizeSuiAddress('0xf1')
+
+function coinRef(objectId: string) {
+ return { objectId, version: '10', digest: DIGEST }
+}
+
+function base(fields: { sender?: string; gasOwner?: string } = {}): Transaction {
+ const tx = new Transaction()
+ tx.setSender(fields.sender ?? ME)
+ tx.setGasOwner(fields.gasOwner ?? fields.sender ?? ME)
+ tx.setGasPrice(1000)
+ tx.setGasBudget(1_000_000)
+ tx.setGasPayment([coinRef(GAS_COIN)])
+ return tx
+}
+
+function owned(tx: Transaction, objectId: string) {
+ return tx.object(Inputs.ObjectRef(coinRef(objectId)))
+}
+
+async function honest(fields: { sender?: string; gasOwner?: string } = {}): Promise {
+ const tx = base(fields)
+ const coin = owned(tx, COIN_A)
+ tx.mergeCoins(coin, [owned(tx, COIN_B)])
+ const [split] = tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ return toBase64(await tx.build())
+}
+
+function check(transaction: string, expectation = EXPECTATION): void {
+ suiDeposit.assertSafe({ family: 'sui', transaction }, expectation)
+}
+
+async function refused(transaction: string, reason: RegExp): Promise {
+ let caught: unknown
+ try {
+ check(transaction)
+ } catch (e) {
+ caught = e
+ }
+ expect(caught).toBeInstanceOf(ChainActionRefused)
+ expect((caught as Error).message).toMatch(reason)
+ expect((caught as Error).message).toMatch(/nothing was signed or sent\.$/i)
+}
+
+async function edited(edit: (tx: Transaction) => void): Promise {
+ const tx = base()
+ edit(tx)
+ return toBase64(await tx.build())
+}
+
+function coinObject(objectId: string, type = USDC, owner = ME) {
+ return {
+ objectId,
+ version: '10',
+ digest: DIGEST,
+ owner: { $kind: 'AddressOwner', AddressOwner: owner },
+ type: `0x2::coin::Coin<${type}>`,
+ }
+}
+
+function listed(balance: string, objectId: string, type = USDC) {
+ return { ...coinObject(objectId, type), balance }
+}
+
+beforeEach(() => {
+ for (const mock of Object.values(core)) mock.mockReset()
+ core.resolveTransactionPlugin.mockReturnValue(
+ async (
+ data: { gasData: { price: unknown; budget: unknown; payment: unknown } },
+ _options: unknown,
+ next: () => Promise
+ ) => {
+ data.gasData.price = '1000'
+ data.gasData.budget = '1000000'
+ data.gasData.payment = [coinRef(GAS_COIN)]
+ await next()
+ }
+ )
+ core.getObjects.mockImplementation(async ({ objectIds }: { objectIds: string[] }) => ({
+ objects: objectIds.map((id) => coinObject(id)),
+ }))
+ core.executeTransaction.mockResolvedValue({
+ Transaction: { digest: 'DIGESTx', effects: { status: { success: true } } },
+ })
+})
+
+describe('what a deposit declares', () => {
+ it('is USDC with six places and no memo', () => {
+ expect(suiDeposit.chain).toBe('sui')
+ expect(suiDeposit.usdcDecimals).toBe(6)
+ expect(suiDeposit.supportsMemo).toBe(false)
+ })
+
+ it('takes full Sui addresses only', () => {
+ expect(suiDeposit.isAccount(ME)).toBe(true)
+ expect(suiDeposit.isDepositAddress(DEPOSIT)).toBe(true)
+ expect(suiDeposit.isAccount('0xnope')).toBe(false)
+ expect(suiDeposit.isDepositAddress('alice.sui')).toBe(false)
+ })
+})
+
+describe('assertSafe admits the one shape', () => {
+ it('merged coins, one split, one transfer', async () => {
+ const transaction = await honest()
+ expect(() => check(transaction)).not.toThrow()
+ })
+
+ it('one coin with no merge', async () => {
+ const transaction = await edited((tx) => {
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ expect(() => check(transaction)).not.toThrow()
+ })
+
+ it('a differently written form of the same addresses', async () => {
+ const transaction = await honest()
+ expect(() =>
+ check(transaction, {
+ ...EXPECTATION,
+ account: ME.toUpperCase().replace('0X', '0x'),
+ depositAddress: DEPOSIT.toUpperCase().replace('0X', '0x'),
+ })
+ ).not.toThrow()
+ })
+})
+
+describe('assertSafe refuses', () => {
+ it('a different recipient', async () => {
+ const transaction = await edited((tx) => {
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([split as never], THIEF)
+ })
+ await refused(transaction, /deposit address/)
+ })
+
+ it('a different amount, one unit either way', async () => {
+ for (const amount of [UNITS + BigInt(1), UNITS - BigInt(1)]) {
+ const transaction = await edited((tx) => {
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [amount])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /amount/)
+ }
+ })
+
+ it('two amounts in the split', async () => {
+ const transaction = await edited((tx) => {
+ const [first, second] = tx.splitCoins(owned(tx, COIN_A), [UNITS, BigInt(1)])
+ tx.transferObjects([first as never, second as never], DEPOSIT)
+ })
+ await refused(transaction, /amount/)
+ })
+
+ it('an extra transfer', async () => {
+ const transaction = await edited((tx) => {
+ const coin = owned(tx, COIN_A)
+ const [split] = tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ const [skim] = tx.splitCoins(coin, [BigInt(1)])
+ tx.transferObjects([skim as never], THIEF)
+ })
+ await refused(transaction, /one USDC transfer/)
+ })
+
+ it('a whole coin sent alongside the split', async () => {
+ const transaction = await edited((tx) => {
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([split as never, owned(tx, COIN_B)], DEPOSIT)
+ })
+ await refused(transaction, /only the split/)
+ })
+
+ it('a whole coin sent instead of the split', async () => {
+ const transaction = await edited((tx) => {
+ const coin = owned(tx, COIN_A)
+ tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([owned(tx, COIN_B)], DEPOSIT)
+ })
+ await refused(transaction, /only the split/)
+ })
+
+ it('a different sender', async () => {
+ await refused(await honest({ sender: THIEF }), /sent from/)
+ })
+
+ it('a different gas owner', async () => {
+ await refused(await honest({ gasOwner: THIEF }), /network fee/)
+ })
+
+ it('a MoveCall, even a harmless looking one', async () => {
+ const transaction = await edited((tx) => {
+ tx.moveCall({
+ target: '0x2::transfer::public_transfer',
+ typeArguments: [`0x2::coin::Coin<${USDC}>`],
+ arguments: [owned(tx, COIN_B), tx.pure.address(THIEF)],
+ })
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /one USDC transfer/)
+ })
+
+ it('splitting the gas coin', async () => {
+ const transaction = await edited((tx) => {
+ const [split] = tx.splitCoins(tx.gas, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /gas coin/)
+ })
+
+ it('merging into the gas coin', async () => {
+ const transaction = await edited((tx) => {
+ const coin = owned(tx, COIN_A)
+ tx.mergeCoins(tx.gas, [owned(tx, COIN_B)])
+ const [split] = tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /gas coin/)
+ })
+
+ it('merging from the gas coin', async () => {
+ const transaction = await edited((tx) => {
+ const coin = owned(tx, COIN_A)
+ tx.mergeCoins(coin, [tx.gas])
+ const [split] = tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /gas coin/)
+ })
+
+ it('sending the gas coin itself', async () => {
+ const transaction = await edited((tx) => {
+ tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([tx.gas], DEPOSIT)
+ })
+ await refused(transaction, /only the split/)
+ })
+
+ it('merging into a coin other than the one split', async () => {
+ const transaction = await edited((tx) => {
+ const coin = owned(tx, COIN_A)
+ const other = owned(tx, COIN_B)
+ tx.mergeCoins(other, [owned(tx, normalizeSuiAddress('0xc3'))])
+ const [split] = tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /merges/)
+ })
+
+ it('the split sent twice', async () => {
+ const transaction = await edited((tx) => {
+ const coin = owned(tx, COIN_A)
+ const [first] = tx.splitCoins(coin, [UNITS])
+ tx.transferObjects([first as never], DEPOSIT)
+ tx.transferObjects([first as never], DEPOSIT)
+ })
+ await refused(transaction, /one USDC transfer/)
+ })
+
+ it('a shared object as the coin', async () => {
+ const transaction = await edited((tx) => {
+ const shared = tx.object(
+ Inputs.SharedObjectRef({
+ objectId: COIN_A,
+ initialSharedVersion: '1',
+ mutable: true,
+ })
+ )
+ const [split] = tx.splitCoins(shared, [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /owned coin/)
+ })
+
+ it('an input the commands never use', async () => {
+ const transaction = await edited((tx) => {
+ owned(tx, COIN_B)
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([split as never], DEPOSIT)
+ })
+ await refused(transaction, /inputs/)
+ })
+
+ it('address-balance funds drawn alongside, from anyone', async () => {
+ const json = JSON.parse(await Transaction.from(await honest()).toJSON()) as {
+ inputs: unknown[]
+ }
+ json.inputs.push({
+ FundsWithdrawal: {
+ reservation: { $kind: 'MaxAmountU64', MaxAmountU64: '1' },
+ typeArg: { $kind: 'Balance', Balance: USDC },
+ withdrawFrom: { $kind: 'Sender', Sender: true },
+ },
+ })
+ const transaction = toBase64(await Transaction.from(JSON.stringify(json)).build())
+ await refused(transaction, /address balance/)
+ })
+
+ it('bytes that are not a transaction', async () => {
+ await refused('!!!', /could not read/)
+ await refused(Buffer.from('hello').toString('base64'), /could not read/)
+ await refused(
+ Buffer.from(await Transaction.from(await honest()).toJSON()).toString('base64'),
+ /could not read/
+ )
+ })
+
+ it('a payload for another chain', async () => {
+ const transaction = await honest()
+ expect(() => suiDeposit.assertSafe({ family: 'solana', transaction }, EXPECTATION)).toThrow(
+ ChainActionRefused
+ )
+ })
+
+ it('an order with a memo', async () => {
+ const transaction = await honest()
+ expect(() => check(transaction, { ...EXPECTATION, memo: 'x' })).toThrow(/memo/)
+ })
+
+ it('an order whose deposit address is the account itself', async () => {
+ const transaction = await honest()
+ expect(() => check(transaction, { ...EXPECTATION, depositAddress: ME })).toThrow(
+ ChainActionRefused
+ )
+ })
+})
+
+describe('build', () => {
+ function coins(...objects: ReturnType[]) {
+ core.listCoins.mockResolvedValue({ objects, hasNextPage: false, cursor: null })
+ }
+
+ it('merges the USDC coins and pays exactly the amount', async () => {
+ coins(listed('20000000', COIN_A), listed('10000000', COIN_B))
+ const payload = await suiDeposit.build(EXPECTATION)
+ expect(payload.family).toBe('sui')
+ if (payload.family !== 'sui') return
+ expect(() => suiDeposit.assertSafe(payload, EXPECTATION)).not.toThrow()
+
+ const data = Transaction.from(payload.transaction).getData()
+ expect(data.commands.map((c) => c.$kind)).toEqual([
+ 'MergeCoins',
+ 'SplitCoins',
+ 'TransferObjects',
+ ])
+ expect(core.listCoins).toHaveBeenCalledWith(
+ expect.objectContaining({ owner: ME, coinType: USDC })
+ )
+ expect(data.sender).toBe(ME)
+ expect(data.gasData.owner).toBe(ME)
+ })
+
+ it('stops reading coins once they cover the amount', async () => {
+ core.listCoins.mockResolvedValueOnce({
+ objects: [listed('30000000', COIN_A)],
+ hasNextPage: true,
+ cursor: 'next',
+ })
+ const payload = await suiDeposit.build(EXPECTATION)
+ if (payload.family !== 'sui') throw new Error('wrong family')
+ expect(core.listCoins).toHaveBeenCalledTimes(1)
+ expect(
+ Transaction.from(payload.transaction)
+ .getData()
+ .commands.map((c) => c.$kind)
+ ).toEqual(['SplitCoins', 'TransferObjects'])
+ })
+
+ it('reads further pages when it needs them', async () => {
+ core.listCoins
+ .mockResolvedValueOnce({
+ objects: [listed('20000000', COIN_A)],
+ hasNextPage: true,
+ cursor: 'next',
+ })
+ .mockResolvedValueOnce({
+ objects: [listed('10000000', COIN_B)],
+ hasNextPage: false,
+ cursor: null,
+ })
+ await expect(suiDeposit.build(EXPECTATION)).resolves.toMatchObject({ family: 'sui' })
+ expect(core.listCoins).toHaveBeenCalledTimes(2)
+ expect(core.listCoins.mock.calls[1]?.[0]).toMatchObject({ cursor: 'next' })
+ })
+
+ it('never spends a coin of another type the network lists', async () => {
+ coins(listed('90000000', COIN_B, USDSUI))
+ core.getBalance.mockResolvedValue({
+ balance: { coinType: USDC, balance: '0', coinBalance: '0', addressBalance: '0' },
+ })
+ await expect(suiDeposit.build(EXPECTATION)).rejects.toThrow(/less USDC/)
+ })
+
+ it('refuses a short balance', async () => {
+ coins(listed('1000000', COIN_A))
+ core.getBalance.mockResolvedValue({
+ balance: {
+ coinType: USDC,
+ balance: '1000000',
+ coinBalance: '1000000',
+ addressBalance: '0',
+ },
+ })
+ const attempt = suiDeposit.build(EXPECTATION)
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/less USDC.*nothing was signed or sent\.$/i)
+ })
+
+ it('refuses USDC held only in the address balance, naming why', async () => {
+ coins()
+ core.getBalance.mockResolvedValue({
+ balance: {
+ coinType: USDC,
+ balance: '90000000',
+ coinBalance: '0',
+ addressBalance: '90000000',
+ },
+ })
+ const attempt = suiDeposit.build(EXPECTATION)
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/address balance.*nothing was signed or sent\.$/i)
+ })
+
+ it('turns a failed build into a refusal the person can act on', async () => {
+ coins(listed('90000000', COIN_A))
+ core.resolveTransactionPlugin.mockReturnValue(async () => {
+ throw new Error('Transaction resolution failed: MoveAbort')
+ })
+ const attempt = suiDeposit.build(EXPECTATION)
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/nothing was signed or sent\.$/i)
+ })
+
+ it.each([
+ ['a memo', { memo: 'x' }],
+ ['a bad account', { account: 'nope' }],
+ ['a bad deposit address', { depositAddress: 'nope' }],
+ ['the account as its own deposit', { depositAddress: ME }],
+ ['too many places', { amount: '1.0000001' }],
+ ['a zero amount', { amount: '0' }],
+ ['an amount past what a coin can hold', { amount: '18446744073710' }],
+ ])('refuses %s before asking the network', async (_name, change) => {
+ const attempt = suiDeposit.build({ ...EXPECTATION, ...change })
+ await expect(attempt).rejects.toBeInstanceOf(ChainActionRefused)
+ await expect(attempt).rejects.toThrow(/nothing was signed or sent\.$/i)
+ expect(core.listCoins).not.toHaveBeenCalled()
+ })
+})
+
+describe('submit', () => {
+ async function signedDeposit() {
+ return { family: 'sui' as const, transaction: await honest(), signature: 'sig' }
+ }
+
+ it('sends a signed deposit made of the account’s USDC coins', async () => {
+ const result = await suiDeposit.submit(await signedDeposit(), EXPECTATION)
+ expect(result).toMatchObject({ ok: true, hash: 'DIGESTx' })
+ expect(core.getObjects).toHaveBeenCalledWith(
+ expect.objectContaining({ objectIds: [COIN_A, COIN_B] })
+ )
+ expect(core.executeTransaction).toHaveBeenCalledTimes(1)
+ })
+
+ it('says a failed deposit in cash-out words, not as a trade', async () => {
+ core.executeTransaction.mockResolvedValue({
+ FailedTransaction: { digest: 'DIGESTx', effects: { status: { success: false } } },
+ })
+ const result = await suiDeposit.submit(await signedDeposit(), EXPECTATION)
+ expect(result.ok).toBe(false)
+ if (result.ok) return
+ expect(result.error).toMatch(/your USDC did not move/i)
+ expect(result.error).not.toMatch(/trade/i)
+ })
+
+ it('checks the signed bytes again and sends nothing if they changed', async () => {
+ const swapped = await edited((tx) => {
+ const [split] = tx.splitCoins(owned(tx, COIN_A), [UNITS])
+ tx.transferObjects([split as never], THIEF)
+ })
+ await expect(
+ suiDeposit.submit({ family: 'sui', transaction: swapped, signature: 'sig' }, EXPECTATION)
+ ).rejects.toBeInstanceOf(ChainActionRefused)
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses a coin that is not USDC, which the bytes alone cannot show', async () => {
+ core.getObjects.mockResolvedValue({
+ objects: [coinObject(COIN_A), coinObject(COIN_B, USDSUI)],
+ })
+ await expect(suiDeposit.submit(await signedDeposit(), EXPECTATION)).rejects.toThrow(/USDC/)
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses a coin the account does not own', async () => {
+ core.getObjects.mockResolvedValue({
+ objects: [coinObject(COIN_A), coinObject(COIN_B, USDC, THIEF)],
+ })
+ await expect(suiDeposit.submit(await signedDeposit(), EXPECTATION)).rejects.toThrow(/USDC/)
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses when a coin cannot be read', async () => {
+ core.getObjects.mockResolvedValue({ objects: [coinObject(COIN_A), new Error('not found')] })
+ await expect(suiDeposit.submit(await signedDeposit(), EXPECTATION)).rejects.toBeInstanceOf(
+ ChainActionRefused
+ )
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the network names fewer coins than were asked about', async () => {
+ core.getObjects.mockResolvedValue({ objects: [coinObject(COIN_A)] })
+ await expect(suiDeposit.submit(await signedDeposit(), EXPECTATION)).rejects.toBeInstanceOf(
+ ChainActionRefused
+ )
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses when the coin read itself fails', async () => {
+ core.getObjects.mockRejectedValue(new Error('down'))
+ await expect(suiDeposit.submit(await signedDeposit(), EXPECTATION)).rejects.toBeInstanceOf(
+ ChainActionRefused
+ )
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('refuses another family without sending', async () => {
+ const result = await suiDeposit.submit({ family: 'near', hashes: ['x'] }, EXPECTATION)
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ expect(core.executeTransaction).not.toHaveBeenCalled()
+ })
+
+ it('reports a send that broke mid-flight as pending, naming the digest', async () => {
+ core.executeTransaction.mockRejectedValue(new Error('socket hang up'))
+ const result = await suiDeposit.submit(await signedDeposit(), EXPECTATION)
+ expect(result).toMatchObject({ ok: false, pending: true })
+ if (!result.ok) expect(result.error).toMatch(/https:\/\/suiscan\.xyz\/mainnet\/tx\/\w+/)
+ })
+
+ it('reports a network rejection as a plain failure', async () => {
+ core.executeTransaction.mockRejectedValue(
+ Object.assign(new Error('nope'), { code: 'INVALID_ARGUMENT' })
+ )
+ const result = await suiDeposit.submit(await signedDeposit(), EXPECTATION)
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-guard.test.ts b/apps/dapp/lib/__tests__/weave-guard.test.ts
new file mode 100644
index 00000000..fb4d37aa
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-guard.test.ts
@@ -0,0 +1,60 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({ mainnet: true, configured: true }))
+
+vi.mock('@intent/config', async (original) => ({
+ ...(await original()),
+ isMainnet: () => state.mainnet,
+}))
+vi.mock('../weave/client', () => ({
+ createWeaveClient: () => ({ configured: state.configured }),
+}))
+
+import { WALLET_HEADER } from '../wallet-header'
+import { guardCashout } from '../weave/guard'
+
+const signedIn = (): Request =>
+ new Request('http://localhost/api/cashout/orders', {
+ headers: { [WALLET_HEADER]: 'alice.near' },
+ })
+
+beforeEach(() => {
+ state.mainnet = true
+ state.configured = true
+})
+
+describe('guardCashout', () => {
+ it('passes a known requester on a configured deployment', () => {
+ expect(guardCashout(signedIn())).toEqual({ ok: true, email: 'wallet:alice.near' })
+ })
+
+ it('needs a requester', async () => {
+ const result = guardCashout(new Request('http://localhost/api/cashout/orders'))
+ if (result.ok) throw new Error('expected a refusal')
+ expect(result.response.status).toBe(401)
+ expect((await result.response.json()).message).toMatch(/connect/i)
+ })
+
+ it('needs a configured client', async () => {
+ state.configured = false
+ const result = guardCashout(signedIn())
+ if (result.ok) throw new Error('expected a refusal')
+ expect(result.response.status).toBe(503)
+ expect((await result.response.json()).message).toMatch(/not available/i)
+ })
+
+ it('refuses a Stellar cash-out off mainnet', () => {
+ state.mainnet = false
+ const result = guardCashout(signedIn(), 'stellar')
+ if (result.ok) throw new Error('expected a refusal')
+ expect(result.response.status).toBe(403)
+ })
+
+ it.each(['solana', 'avalanche', 'sui', 'near'] as const)(
+ 'lets %s through whatever Stellar network the page is on',
+ (chain) => {
+ state.mainnet = false
+ expect(guardCashout(signedIn(), chain).ok).toBe(true)
+ }
+ )
+})
diff --git a/apps/dapp/lib/__tests__/weave-orders-route.test.ts b/apps/dapp/lib/__tests__/weave-orders-route.test.ts
new file mode 100644
index 00000000..7d24d7cf
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-orders-route.test.ts
@@ -0,0 +1,528 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+ mainnet: true,
+ weave: {} as Record,
+ repo: {} as Record,
+ deposit: {} as Record,
+ report: [] as unknown[][],
+ session: undefined as { email: string } | undefined,
+}))
+
+vi.mock('../server/session', () => ({ readSessionFromRequest: () => state.session }))
+vi.mock('@intent/config', async (original) => ({
+ ...(await original()),
+ isMainnet: () => state.mainnet,
+}))
+vi.mock('../weave/client', async (original) => ({
+ ...(await original()),
+ createWeaveClient: () => state.weave,
+}))
+vi.mock('../weave/orders', async (original) => ({
+ ...(await original()),
+ getWeaveOrderRepo: async () => state.repo,
+}))
+vi.mock('../weave/deposit', () => ({ depositChain: () => state.deposit }))
+vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined }))
+vi.mock('../server/report', () => ({
+ reportError: (...args: unknown[]) => state.report.push(args),
+}))
+
+import { POST } from '../../app/api/cashout/orders/route'
+import { WALLET_HEADER } from '../wallet-header'
+
+const KEY = '3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456'
+const DIGITS = '254700001234'
+const HOLDER = 'JANE ADEYEMI'
+const REQUESTER = 'wallet:alice.near'
+
+const body = {
+ chain: 'near',
+ account: 'alice.near',
+ amount: '25.5',
+ currency: 'KES',
+ institution: 'MPESA',
+ accountIdentifier: DIGITS,
+ idempotencyKey: KEY,
+}
+
+function post(over: Record = {}, headers: Record = {}): Request {
+ return new Request('http://localhost/api/cashout/orders', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', [WALLET_HEADER]: 'alice.near', ...headers },
+ body: JSON.stringify({ ...body, ...over }),
+ })
+}
+
+const placed = {
+ id: 'ord_1',
+ status: 'awaiting_deposit',
+ sourceAssetKey: 'crypto:NEAR:USDC',
+ destAssetKey: 'fiat:KES',
+ deposit: {
+ address: 'weave.near',
+ memo: undefined,
+ amount: '25.5',
+ token: 'USDC',
+ chain: 'NEAR',
+ expiresAt: '2026-11-04T13:00:00.000Z',
+ },
+}
+
+function weave(
+ over: Record = {}
+): Record | boolean> {
+ return {
+ configured: true,
+ institutions: vi.fn(async () => ({
+ ok: true,
+ value: [{ name: 'M-Pesa', code: 'MPESA', type: 'mobile_money' }],
+ })),
+ verifyAccount: vi.fn(async () => ({ ok: true, value: { accountName: HOLDER } })),
+ quote: vi.fn(async () => ({
+ ok: true,
+ value: { destAmount: 3200.5, netUsd: 25, seconds: 90 },
+ })),
+ createOrder: vi.fn(async () => ({ ok: true, value: placed })),
+ getOrder: vi.fn(),
+ ...over,
+ } as Record | boolean>
+}
+
+function repo(over: Record = {}): Record> {
+ return {
+ getByIdempotencyKey: vi.fn(async () => undefined),
+ hasUnconfirmedSend: vi.fn(async () => false),
+ findHolder: vi.fn(async () => undefined),
+ insert: vi.fn(async () => undefined),
+ ...over,
+ } as Record>
+}
+
+const called = (fn: unknown): ReturnType => fn as ReturnType
+
+beforeEach(() => {
+ state.mainnet = true
+ state.report = []
+ state.session = undefined
+ state.weave = weave()
+ state.repo = repo()
+ state.deposit = {
+ usdcDecimals: 6,
+ supportsMemo: false,
+ isAccount: (a: string) => a.endsWith('.near'),
+ isDepositAddress: (a: string) => a.endsWith('.near'),
+ }
+ vi.stubEnv('WEAVE_CASHOUT_CURRENCIES', 'NGN,KES')
+ vi.stubEnv('MAINNET_MAX_TRADE_USD', '')
+})
+
+describe('checks before anything is asked of Weave', () => {
+ async function refused(request: Request, status: number): Promise> {
+ const res = await POST(request)
+ expect(res.status).toBe(status)
+ expect(called(state.weave['createOrder'])).not.toHaveBeenCalled()
+ expect(called(state.repo['insert'])).not.toHaveBeenCalled()
+ return (await res.json()) as Record
+ }
+
+ it('needs a requester', async () => {
+ await refused(
+ new Request('http://localhost/api/cashout/orders', { method: 'POST', body: '{}' }),
+ 401
+ )
+ })
+
+ it('needs a configured client', async () => {
+ state.weave = weave({ configured: false })
+ await refused(post(), 503)
+ })
+
+ it.each([
+ ['an unknown chain', { chain: 'ethereum' }],
+ ['an amount with too many places', { amount: '1.1234567' }],
+ ['an amount that is not decimal text', { amount: 25 }],
+ ['a zero amount', { amount: '0' }],
+ ['an account that is not on the chain', { account: 'alice.sol' }],
+ ['a currency that is not live', { currency: 'UGX' }],
+ ['an unknown currency', { currency: 'BRL' }],
+ ['a key that is not a uuid', { idempotencyKey: 'x' }],
+ ['a malformed account identifier', { accountIdentifier: '12 34;' }],
+ ])('refuses %s', async (_name, over) => {
+ await refused(post(over), 400)
+ })
+
+ it('refuses Stellar naira, which stays on the Linq flow', async () => {
+ await refused(post({ chain: 'stellar', currency: 'NGN' }), 400)
+ })
+
+ it('refuses a Stellar cash-out off mainnet', async () => {
+ state.mainnet = false
+ await refused(post({ chain: 'stellar', currency: 'KES' }), 403)
+ })
+
+ it('still serves other chains when the page is on Stellar testnet', async () => {
+ state.mainnet = false
+ expect((await POST(post())).status).toBe(200)
+ })
+
+ it('refuses a body that is not JSON', async () => {
+ await refused(
+ new Request('http://localhost/api/cashout/orders', {
+ method: 'POST',
+ headers: { [WALLET_HEADER]: 'alice.near' },
+ body: 'nope',
+ }),
+ 400
+ )
+ })
+
+ it('refuses an institution Weave does not list for the currency', async () => {
+ await refused(post({ institution: 'OTHER' }), 400)
+ })
+})
+
+describe('a retry of the same attempt', () => {
+ const existing = {
+ id: KEY,
+ chain: 'near',
+ status: 'waiting',
+ providerStatus: 'awaiting_deposit',
+ accountIdentifier: DIGITS,
+ accountName: HOLDER,
+ depositHash: undefined,
+ expiresAt: undefined,
+ amount: '25.5',
+ depositAddress: 'weave.near',
+ }
+
+ it('stores and shows what Weave needs to receive, when it runs a little over', async () => {
+ state.weave = weave({
+ createOrder: vi.fn(async () => ({
+ ok: true,
+ value: { ...placed, deposit: { ...placed.deposit, amount: '25.77' } },
+ })),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(200)
+ expect((await res.json()).order.amount).toBe('25.77')
+ expect(called(state.repo['insert']).mock.calls[0]?.[0].amount).toBe('25.77')
+ })
+
+ it('refuses a new order while an earlier deposit from this wallet is unconfirmed', async () => {
+ state.repo = repo({ hasUnconfirmedSend: vi.fn(async () => true) })
+ const res = await POST(post())
+ expect(res.status).toBe(409)
+ const json = await res.json()
+ expect(json.error).toBe('send_unconfirmed')
+ expect(json.message).toMatch(/not confirmed/i)
+ expect(state.repo['insert']).not.toHaveBeenCalled()
+ })
+
+ it('returns the order already made, scoped to this requester', async () => {
+ state.repo = repo({ getByIdempotencyKey: vi.fn(async () => existing) })
+ const res = await POST(post())
+ expect(res.status).toBe(200)
+ expect((await res.json()).order.depositAddress).toBe('weave.near')
+ expect(called(state.repo['getByIdempotencyKey'])).toHaveBeenCalledWith(KEY, REQUESTER)
+ expect(called(state.weave['createOrder'])).not.toHaveBeenCalled()
+ expect(called(state.weave['quote'])).not.toHaveBeenCalled()
+ })
+
+ it('says an order that ended unpaid is closed, rather than handing it back', async () => {
+ state.repo = repo({
+ getByIdempotencyKey: vi.fn(async () => ({ ...existing, status: 'expired' })),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(409)
+ const json = await res.json()
+ expect(json.error).toBe('order_closed')
+ expect(JSON.stringify(json)).not.toContain('weave.near')
+ })
+
+ it('says an order past its expiry is closed', async () => {
+ state.repo = repo({
+ getByIdempotencyKey: vi.fn(async () => ({
+ ...existing,
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ })),
+ })
+ expect((await POST(post())).status).toBe(409)
+ })
+})
+
+describe('the cap', () => {
+ it('refuses above the cap in a plain sentence, asking Weave nothing', async () => {
+ const res = await POST(post({ amount: '501' }))
+ expect(res.status).toBe(400)
+ const { message } = await res.json()
+ expect(message).toContain('$500')
+ expect(message).toContain('$501.00')
+ expect(message).not.toContain('MAINNET_MAX_TRADE_USD')
+ expect(called(state.weave['quote'])).not.toHaveBeenCalled()
+ })
+
+ it('reads the cap as mainnet whatever the page is on', async () => {
+ state.mainnet = false
+ expect((await POST(post({ amount: '501' }))).status).toBe(400)
+ })
+
+ it('allows exactly the cap', async () => {
+ called(state.weave['quote']).mockResolvedValueOnce({
+ ok: true,
+ value: { destAmount: 1, netUsd: 500, seconds: 1 },
+ })
+ called(state.weave['createOrder']).mockResolvedValueOnce({
+ ok: true,
+ value: { ...placed, deposit: { ...placed.deposit, amount: '500' } },
+ })
+ expect((await POST(post({ amount: '500' }))).status).toBe(200)
+ })
+})
+
+describe('the holder', () => {
+ it('comes from the server lookup and never from the browser', async () => {
+ await POST(post({ accountName: 'SOMEONE ELSE', bankAccountName: 'SOMEONE ELSE' }))
+ expect(called(state.weave['verifyAccount'])).toHaveBeenCalledWith({
+ institution: 'MPESA',
+ accountIdentifier: DIGITS,
+ currency: 'KES',
+ })
+ expect(called(state.weave['createOrder']).mock.calls[0]?.[0].accountName).toBe(HOLDER)
+ expect(called(state.repo['insert']).mock.calls[0]?.[0].accountName).toBe(HOLDER)
+ })
+
+ it("can come from this requester's earlier order to the same account", async () => {
+ state.repo = repo({ findHolder: vi.fn(async () => 'JANE FROM BEFORE') })
+ await POST(post())
+ expect(called(state.repo['findHolder'])).toHaveBeenCalledWith(REQUESTER, 'KES', 'MPESA', DIGITS)
+ expect(called(state.weave['verifyAccount'])).not.toHaveBeenCalled()
+ expect(called(state.weave['createOrder']).mock.calls[0]?.[0].accountName).toBe(
+ 'JANE FROM BEFORE'
+ )
+ })
+
+ it('asks Weave when the record cannot be read', async () => {
+ state.repo = repo({
+ findHolder: vi.fn(async () => {
+ throw new Error('db down')
+ }),
+ })
+ expect((await POST(post())).status).toBe(200)
+ expect(called(state.weave['verifyAccount'])).toHaveBeenCalled()
+ })
+
+ it('makes no order for an account that cannot be verified', async () => {
+ state.weave = weave({
+ verifyAccount: vi.fn(async () => ({ ok: false, code: 'rejected', message: 'No.' })),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(422)
+ expect(called(state.weave['createOrder'])).not.toHaveBeenCalled()
+ })
+})
+
+describe('the quote', () => {
+ const netUsd = (value: number) =>
+ vi.fn(async () => ({ ok: true, value: { destAmount: 100, netUsd: value, seconds: 1 } }))
+
+ it('refuses a cost above five percent worked out from netUsd', async () => {
+ state.weave = weave({ quote: netUsd(24.2) })
+ const res = await POST(post())
+ expect(res.status).toBe(422)
+ const { message } = await res.json()
+ expect(message).toMatch(/5%/)
+ expect(called(state.weave['createOrder'])).not.toHaveBeenCalled()
+ })
+
+ it('allows a cost of exactly five percent', async () => {
+ state.weave = weave({
+ quote: netUsd(95),
+ createOrder: vi.fn(async () => ({
+ ok: true,
+ value: { ...placed, deposit: { ...placed.deposit, amount: '100' } },
+ })),
+ })
+ expect((await POST(post({ amount: '100' }))).status).toBe(200)
+ })
+
+ it('refuses just above five percent', async () => {
+ state.weave = weave({ quote: netUsd(94.99) })
+ expect((await POST(post({ amount: '100' }))).status).toBe(422)
+ })
+
+ it('makes no order without a quote', async () => {
+ state.weave = weave({
+ quote: vi.fn(async () => ({ ok: false, code: 'rejected', message: 'No price.' })),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(422)
+ expect((await res.json()).message).toBe('No price.')
+ expect(called(state.weave['createOrder'])).not.toHaveBeenCalled()
+ })
+})
+
+describe('checking the answer against what was asked', () => {
+ const answering = (deposit: Record, extra: Record = {}) => {
+ state.weave = weave({
+ createOrder: vi.fn(async () => ({
+ ok: true,
+ value: { ...placed, ...extra, deposit: { ...placed.deposit, ...deposit } },
+ })),
+ })
+ }
+
+ it.each([
+ ['chain', { chain: 'SOLANA' }, {}],
+ ['token', { token: 'USDT' }, {}],
+ ['amount', { amount: '26.1' }, {}],
+ ['address', { address: 'evil.sol' }, {}],
+ ['memo', { memo: 'M1' }, {}],
+ ['dest_asset', {}, { destAssetKey: 'fiat:NGN' }],
+ ])('stores and returns nothing on a wrong %s', async (field, deposit, extra) => {
+ answering(deposit, extra)
+ const res = await POST(post())
+ expect(res.status).toBe(502)
+ const json = await res.json()
+ expect(json.message).toMatch(/nothing was signed or sent/i)
+ expect(JSON.stringify(json)).not.toMatch(/weave\.near|evil\.sol|M1/)
+ expect(called(state.repo['insert'])).not.toHaveBeenCalled()
+
+ expect(state.report).toHaveLength(1)
+ const report = JSON.stringify(state.report[0])
+ expect(report).toContain(field)
+ expect(report).not.toContain(DIGITS)
+ expect(report).not.toContain(HOLDER)
+ expect(report).not.toContain('M1')
+ })
+
+ it('accepts a memo on a chain that can carry one, and stores it', async () => {
+ state.deposit = { ...state.deposit, supportsMemo: true }
+ answering({ memo: 'M1' })
+ const res = await POST(post())
+ expect(res.status).toBe(200)
+ expect(called(state.repo['insert']).mock.calls[0]?.[0].depositMemo).toBe('M1')
+ expect((await res.json()).order.depositMemo).toBe('M1')
+ })
+
+ it('stores an empty memo as no memo', async () => {
+ answering({ memo: '' })
+ expect((await POST(post())).status).toBe(200)
+ expect(called(state.repo['insert']).mock.calls[0]?.[0].depositMemo).toBeUndefined()
+ })
+
+ it('accepts lower-case names and an amount written differently', async () => {
+ answering({ chain: 'near', token: 'usdc', amount: '25.5000000' })
+ expect((await POST(post())).status).toBe(200)
+ })
+})
+
+describe('what Weave could not answer', () => {
+ it('answers a duplicate reference with no local row without making a second', async () => {
+ state.weave = weave({
+ createOrder: vi.fn(async () => ({
+ ok: false,
+ code: 'duplicate',
+ message: 'This attempt could not be confirmed. Review it again to start a new one.',
+ })),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(409)
+ const json = await res.json()
+ expect(json.error).toBe('order_closed')
+ expect(json.message).toMatch(/could not be confirmed/)
+ expect(called(state.weave['createOrder'])).toHaveBeenCalledTimes(1)
+ expect(called(state.repo['insert'])).not.toHaveBeenCalled()
+ })
+
+ it('says an unknown outcome is unknown', async () => {
+ state.weave = weave({
+ createOrder: vi.fn(async () => ({ ok: false, code: 'unknown', message: 'No clear answer.' })),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(504)
+ expect(called(state.repo['insert'])).not.toHaveBeenCalled()
+ })
+
+ it('reports an answer it could not read, with no account details', async () => {
+ state.weave = weave({
+ createOrder: vi.fn(async () => ({ ok: false, code: 'unexpected', message: 'Unreadable.' })),
+ })
+ expect((await POST(post())).status).toBe(502)
+ expect(state.report).toHaveLength(1)
+ expect(JSON.stringify(state.report[0])).not.toContain(DIGITS)
+ })
+})
+
+describe('a good order', () => {
+ it('sends the request to Weave with the wallet as refund address', async () => {
+ await POST(post())
+ expect(called(state.weave['createOrder'])).toHaveBeenCalledWith({
+ chain: 'near',
+ currency: 'KES',
+ amount: '25.5',
+ account: 'alice.near',
+ institutionCode: 'MPESA',
+ accountIdentifier: DIGITS,
+ accountName: HOLDER,
+ reference: KEY,
+ customer: { externalId: REQUESTER },
+ })
+ })
+
+ it('tells Weave the email when there is a real one', async () => {
+ state.session = { email: 'user@example.com' }
+ await POST(post())
+ expect(called(state.weave['createOrder']).mock.calls[0]?.[0].customer).toEqual({
+ email: 'user@example.com',
+ })
+ })
+
+ it('stores the row from what was asked and answered', async () => {
+ await POST(post())
+ expect(called(state.repo['insert']).mock.calls[0]?.[0]).toMatchObject({
+ chain: 'near',
+ email: REQUESTER,
+ account: 'alice.near',
+ providerOrder: 'ord_1',
+ idempotencyKey: KEY,
+ currency: 'KES',
+ institutionCode: 'MPESA',
+ institutionName: 'M-Pesa',
+ accountIdentifier: DIGITS,
+ accountName: HOLDER,
+ depositAddress: 'weave.near',
+ amount: '25.5',
+ estimatedDest: '3200.5',
+ expiresAt: '2026-11-04T13:00:00.000Z',
+ status: 'waiting',
+ providerStatus: 'awaiting_deposit',
+ })
+ })
+
+ it('returns only the masked account', async () => {
+ const res = await POST(post())
+ expect(res.status).toBe(200)
+ const text = JSON.stringify(await res.json())
+ expect(text).not.toContain(DIGITS)
+ expect(text).toContain('••••1234')
+ })
+
+ it('never hands out a deposit address it could not record', async () => {
+ state.repo = repo({
+ insert: vi.fn(async () => {
+ throw new Error(`insert ${DIGITS} ${HOLDER} failed`)
+ }),
+ })
+ const res = await POST(post())
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(JSON.stringify(json)).not.toContain('weave.near')
+ expect(json.message).toMatch(/nothing was signed or sent/i)
+ const report = JSON.stringify(
+ state.report.map((r) => [r[0], String((r[1] as Error).message), r[2]])
+ )
+ expect(report).not.toContain(DIGITS)
+ expect(report).not.toContain(HOLDER)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-orders.test.ts b/apps/dapp/lib/__tests__/weave-orders.test.ts
new file mode 100644
index 00000000..e0ea6568
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-orders.test.ts
@@ -0,0 +1,411 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { WeaveOrder } from '../weave/client'
+import {
+ cleanMemo,
+ createWeaveOrderRepo,
+ depositExpectation,
+ orderMismatch,
+ payRefusal,
+ readUsdcAmount,
+ safeForReport,
+ syncOrder,
+ toPublic,
+ WEAVE_DDL,
+ type WeaveOrderRow,
+} from '../weave/orders'
+
+const ID = '3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456'
+const NOW = Date.parse('2026-11-04T12:00:00.000Z')
+
+function row(over: Partial = {}): WeaveOrderRow {
+ return {
+ id: ID,
+ chain: 'near',
+ email: 'user@example.com',
+ account: 'alice.near',
+ providerOrder: 'ord_1',
+ idempotencyKey: 'key',
+ currency: 'KES',
+ institutionCode: 'MPESA',
+ institutionName: 'M-Pesa',
+ accountIdentifier: '254700001234',
+ accountName: 'JANE ADEYEMI',
+ depositAddress: 'weave.near',
+ depositMemo: undefined,
+ amount: '25.5',
+ estimatedDest: '3200.5',
+ expiresAt: '2026-11-04T13:00:00.000Z',
+ status: 'waiting',
+ providerStatus: 'awaiting_deposit',
+ depositHash: undefined,
+ sendStarted: false,
+ createdAt: '2026-11-04T11:00:00.000Z',
+ ...over,
+ }
+}
+
+describe('toPublic', () => {
+ it('masks the account identifier and never returns it whole', () => {
+ const view = toPublic(row())
+ expect(view.accountMask).toBe('••••1234')
+ expect(JSON.stringify(view)).not.toContain('254700001234')
+ })
+
+ it('shows nothing of an identifier too short to mask', () => {
+ expect(toPublic(row({ accountIdentifier: '1234' })).accountMask).toBe('••••')
+ })
+
+ it('leaves out who made the order and the provider ids', () => {
+ const text = JSON.stringify(toPublic(row()))
+ expect(text).not.toContain('user@example.com')
+ expect(text).not.toContain('ord_1')
+ })
+})
+
+describe('readUsdcAmount', () => {
+ it('reads decimal text and tidies it', () => {
+ expect(readUsdcAmount('25.50', 6)).toBe('25.5')
+ expect(readUsdcAmount('007', 6)).toBe('7')
+ expect(readUsdcAmount('1.000000000', 6)).toBe('1')
+ })
+
+ it('refuses more places than the chain has', () => {
+ expect(readUsdcAmount('1.1234567', 6)).toBeUndefined()
+ expect(readUsdcAmount('1.1234567', 7)).toBe('1.1234567')
+ })
+
+ it.each(['0', '0.00', '-1', '1e3', '', ' ', '.5', '1.', '0x10', 'NaN'])('refuses %j', (text) => {
+ expect(readUsdcAmount(text, 6)).toBeUndefined()
+ })
+})
+
+describe('orderMismatch', () => {
+ const asked = { chain: 'near', currency: 'KES', amount: '25.5' } as const
+ const near = {
+ isDepositAddress: (a: string) => a.endsWith('.near'),
+ supportsMemo: false,
+ usdcDecimals: 6,
+ }
+ const answer: WeaveOrder = {
+ id: 'ord_1',
+ status: 'awaiting_deposit',
+ sourceAssetKey: 'crypto:NEAR:USDC',
+ destAssetKey: 'fiat:KES',
+ deposit: {
+ address: 'weave.near',
+ memo: undefined,
+ amount: '25.5',
+ token: 'USDC',
+ chain: 'NEAR',
+ },
+ }
+ const withDeposit = (over: Partial): WeaveOrder => ({
+ ...answer,
+ deposit: { ...answer.deposit, ...over },
+ })
+
+ it('accepts an answer that matches', () => {
+ expect(orderMismatch(answer, asked, near)).toBeUndefined()
+ })
+
+ it('compares chain and token without regard to case', () => {
+ expect(
+ orderMismatch(withDeposit({ chain: 'near', token: 'usdc' }), asked, near)
+ ).toBeUndefined()
+ })
+
+ it('compares the amount as a number, not as text', () => {
+ expect(orderMismatch(withDeposit({ amount: '25.50' }), asked, near)).toBeUndefined()
+ expect(orderMismatch(withDeposit({ amount: '025.500000' }), asked, near)).toBeUndefined()
+ })
+
+ it('allows the fee Weave adds on top, as seen live: 5.05322 asked for on 5', () => {
+ expect(
+ orderMismatch(withDeposit({ amount: '5.05322' }), { ...asked, amount: '5' }, near)
+ ).toBeUndefined()
+ expect(orderMismatch(withDeposit({ amount: '26.01' }), asked, near)).toBeUndefined()
+ })
+
+ it.each([
+ ['more than 2% over', '26.02'],
+ ['less than was asked', '25.49'],
+ ['far too much', '255'],
+ ['more places than the token has', '25.5000001'],
+ ['not a plain amount', '1e3'],
+ ])('refuses a deposit of %s', (_name, amount) => {
+ expect(orderMismatch(withDeposit({ amount }), asked, near)).toBe('amount')
+ })
+
+ it.each([
+ ['chain', { chain: 'SOLANA' }],
+ ['token', { token: 'USDT' }],
+ ['address', { address: 'weave.sol' }],
+ ['memo', { memo: 'M1' }],
+ ])('names a wrong %s', (field, over) => {
+ expect(orderMismatch(withDeposit(over), asked, near)).toBe(field)
+ })
+
+ it('allows a memo on a chain that can carry one', () => {
+ expect(
+ orderMismatch(withDeposit({ memo: 'M1' }), asked, { ...near, supportsMemo: true })
+ ).toBeUndefined()
+ })
+
+ it('reads an empty memo as no memo on a chain that cannot carry one', () => {
+ expect(orderMismatch(withDeposit({ memo: '' }), asked, near)).toBeUndefined()
+ })
+
+ it('names a wrong source or destination asset when the answer states them', () => {
+ expect(orderMismatch({ ...answer, sourceAssetKey: 'crypto:NEAR:USDT' }, asked, near)).toBe(
+ 'source_asset'
+ )
+ expect(orderMismatch({ ...answer, destAssetKey: 'fiat:NGN' }, asked, near)).toBe('dest_asset')
+ })
+
+ it('does not need the asset keys', () => {
+ const { sourceAssetKey: _source, destAssetKey: _dest, ...bare } = answer
+ expect(orderMismatch(bare, asked, near)).toBeUndefined()
+ })
+})
+
+describe('syncOrder', () => {
+ const repo = () => ({ setStatus: vi.fn(async () => undefined) })
+
+ it('records what Weave says and returns the moved row', async () => {
+ const r = repo()
+ const synced = await syncOrder(r, row(), {
+ getOrder: async () => ({ ok: true, value: { id: 'ord_1', status: 'processing' } }),
+ })
+ expect(synced).toMatchObject({ reached: true, row: { status: 'processing' } })
+ expect(r.setStatus).toHaveBeenCalledWith('ord_1', 'processing', 'processing')
+ })
+
+ it('writes nothing when nothing changed', async () => {
+ const r = repo()
+ await syncOrder(r, row(), {
+ getOrder: async () => ({ ok: true, value: { id: 'ord_1', status: 'awaiting_deposit' } }),
+ })
+ expect(r.setStatus).not.toHaveBeenCalled()
+ })
+
+ it('says when Weave could not be asked, and keeps the row', async () => {
+ const r = repo()
+ const synced = await syncOrder(r, row(), {
+ getOrder: async () => ({ ok: false, code: 'unavailable', message: 'x' }),
+ })
+ expect(synced.reached).toBe(false)
+ expect(synced.row.status).toBe('waiting')
+ expect(r.setStatus).not.toHaveBeenCalled()
+ })
+
+ it('does not ask about an order that has ended', async () => {
+ const r = repo()
+ const getOrder = vi.fn()
+ const synced = await syncOrder(r, row({ status: 'paid', providerStatus: 'completed' }), {
+ getOrder,
+ })
+ expect(getOrder).not.toHaveBeenCalled()
+ expect(synced.reached).toBe(true)
+ })
+})
+
+describe('payRefusal', () => {
+ const reached = (over: Partial = {}) => ({ row: row(over), reached: true })
+
+ it('allows an order positively waiting, unpaid and in time', () => {
+ expect(payRefusal(reached(), NOW)).toBeUndefined()
+ })
+
+ it('allows an order with no expiry', () => {
+ expect(payRefusal(reached({ expiresAt: undefined }), NOW)).toBeUndefined()
+ })
+
+ it('refuses when Weave could not be asked', () => {
+ expect(payRefusal({ row: row(), reached: false }, NOW)).toMatchObject({ status: 503 })
+ })
+
+ it('refuses a status it cannot read, even though the order stays waiting', () => {
+ expect(payRefusal(reached({ providerStatus: 'settling' }), NOW)).toMatchObject({ status: 503 })
+ })
+
+ it.each(['processing', 'paid', 'failed', 'expired', 'refunded'] as const)(
+ 'refuses %s',
+ (status) => {
+ expect(payRefusal(reached({ status }), NOW)).toMatchObject({ status: 409 })
+ }
+ )
+
+ it('refuses an order that already has its deposit', () => {
+ expect(payRefusal(reached({ depositHash: 'abc' }), NOW)).toMatchObject({ status: 409 })
+ })
+
+ it('refuses an order past its expiry', () => {
+ expect(payRefusal(reached({ expiresAt: '2026-11-04T11:59:59.000Z' }), NOW)).toMatchObject({
+ status: 409,
+ })
+ })
+})
+
+describe('depositExpectation', () => {
+ it('comes from the row alone', () => {
+ expect(depositExpectation(row({ depositMemo: 'M1' }))).toEqual({
+ account: 'alice.near',
+ depositAddress: 'weave.near',
+ memo: 'M1',
+ amount: '25.5',
+ })
+ })
+
+ it('never carries an empty memo, which chains would refuse', () => {
+ expect(depositExpectation(row({ depositMemo: '' })).memo).toBeUndefined()
+ })
+})
+
+describe('cleanMemo', () => {
+ it('reads null and empty as no memo', () => {
+ expect(cleanMemo(null)).toBeUndefined()
+ expect(cleanMemo(undefined)).toBeUndefined()
+ expect(cleanMemo('')).toBeUndefined()
+ expect(cleanMemo('M1')).toBe('M1')
+ })
+})
+
+describe('safeForReport', () => {
+ it('strips the account digits, holder and memo from an error', () => {
+ const r = row({ depositMemo: 'MEMO99' })
+ const clean = safeForReport(
+ new Error('send to 254700001234 JANE ADEYEMI memo MEMO99 failed'),
+ r
+ )
+ expect(String(clean.message)).toBe('send to [redacted] [redacted] memo [redacted] failed')
+ expect(String(clean.stack)).not.toMatch(/254700001234|JANE|MEMO99/)
+ })
+})
+
+describe('createWeaveOrderRepo', () => {
+ function fake(rows: Record[] = []) {
+ const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows }))
+ return { query, repo: createWeaveOrderRepo(query) }
+ }
+
+ it('creates the table with the unique columns', async () => {
+ const { query, repo } = fake()
+ await repo.ensureSchema()
+ expect(query).toHaveBeenCalledTimes(WEAVE_DDL.length)
+ const ddl = WEAVE_DDL.join('\n')
+ expect(ddl).toMatch(/provider_order\s+TEXT\s+NOT NULL UNIQUE/)
+ expect(ddl).toMatch(/idempotency_key\s+TEXT\s+NOT NULL UNIQUE/)
+ expect(ddl).toMatch(/deposit_hash\s+TEXT\s+UNIQUE/)
+ })
+
+ it('says a hash was stored only when this order now holds it', async () => {
+ const { repo } = fake([{ id: ID }])
+ expect(await repo.setDepositHash(ID, 'abc')).toBe('stored')
+ })
+
+ it('says reused when this order already holds a different hash', async () => {
+ const { repo } = fake([])
+ expect(await repo.setDepositHash(ID, 'abc')).toBe('reused')
+ })
+
+ it('says reused when another order holds the hash', async () => {
+ const repo = createWeaveOrderRepo(async () => {
+ throw Object.assign(new Error('duplicate key'), { code: '23505' })
+ })
+ expect(await repo.setDepositHash(ID, 'abc')).toBe('reused')
+ })
+
+ it('lets any other database failure through', async () => {
+ const repo = createWeaveOrderRepo(async () => {
+ throw new Error('down')
+ })
+ await expect(repo.setDepositHash(ID, 'abc')).rejects.toThrow('down')
+ })
+
+ it('reads an empty stored memo as no memo', async () => {
+ const { repo } = fake([
+ {
+ id: ID,
+ chain: 'near',
+ deposit_memo: '',
+ status: 'waiting',
+ created_at: new Date('2026-11-04T11:00:00.000Z'),
+ },
+ ])
+ expect((await repo.getForEmail(ID, 'a@b.c'))?.depositMemo).toBeUndefined()
+ })
+
+ it('stores an empty memo as null', async () => {
+ const { query, repo } = fake()
+ const { createdAt: _created, depositHash: _hash, ...fresh } = row({ depositMemo: '' })
+ await repo.insert(fresh)
+ expect(query.mock.calls[0]?.[1]?.[12]).toBeNull()
+ })
+
+ it('scopes reads by email', async () => {
+ const { query, repo } = fake()
+ await repo.getForEmail(ID, 'a@b.c')
+ await repo.getByIdempotencyKey('k', 'a@b.c')
+ await repo.listForEmail('a@b.c', 50)
+ await repo.findHolder('a@b.c', 'KES', 'MPESA', '1')
+ for (const [sql, params] of query.mock.calls) {
+ expect(sql).toMatch(/email = \$\d/)
+ expect(params).toContain('a@b.c')
+ }
+ })
+
+ it('does not send an id that is not a uuid to the database', async () => {
+ const { query, repo } = fake()
+ expect(await repo.getForEmail('not-a-uuid', 'a@b.c')).toBeUndefined()
+ expect(query).not.toHaveBeenCalled()
+ })
+
+ it('never overwrites a final status or goes back to waiting', async () => {
+ const { query, repo } = fake()
+ await repo.setStatus('ord_1', 'processing', 'processing')
+ const sql = String(query.mock.calls[0]?.[0])
+ expect(sql).toMatch(/status NOT IN \('paid', 'failed', 'expired', 'refunded'\)/)
+ expect(sql).toMatch(/NOT \(status <> 'waiting' AND \$2 = 'waiting'\)/)
+ })
+
+ it('keeps the first deposit hash', async () => {
+ const { query, repo } = fake()
+ await repo.setDepositHash(ID, 'abc')
+ expect(String(query.mock.calls[0]?.[0])).toMatch(/deposit_hash IS NULL/)
+ })
+
+ it('reads a row with nothing optional set', async () => {
+ const { repo } = fake([
+ {
+ id: ID,
+ chain: 'near',
+ email: 'a@b.c',
+ account: 'alice.near',
+ provider_order: 'ord_1',
+ idempotency_key: 'k',
+ currency: 'KES',
+ institution_code: 'MPESA',
+ institution_name: 'M-Pesa',
+ account_identifier: '123456',
+ account_name: 'JANE',
+ deposit_address: 'weave.near',
+ deposit_memo: null,
+ amount: '25.5',
+ estimated_dest: '3200.5',
+ expires_at: null,
+ status: 'waiting',
+ provider_status: 'awaiting_deposit',
+ deposit_hash: null,
+ created_at: new Date('2026-11-04T11:00:00.000Z'),
+ },
+ ])
+ const found = await repo.getForEmail(ID, 'a@b.c')
+ expect(found).toMatchObject({
+ depositMemo: undefined,
+ expiresAt: undefined,
+ depositHash: undefined,
+ createdAt: '2026-11-04T11:00:00.000Z',
+ })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts
new file mode 100644
index 00000000..effa8018
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-parse-cashout.test.ts
@@ -0,0 +1,242 @@
+import { describe, expect, it } from 'vitest'
+
+import { parseCashout, parseCurrencyAnswer, routeCashout } from '../weave/parse-cashout'
+
+describe('parseCurrencyAnswer', () => {
+ it.each([
+ ['KES', 'KES'],
+ ['kes', 'KES'],
+ ['Kenya', 'KES'],
+ ['Kenyan shillings', 'KES'],
+ ['to kenya', 'KES'],
+ ['in KES please', 'KES'],
+ ['kenyan', 'KES'],
+ ['naira', 'NGN'],
+ ['Nigeria.', 'NGN'],
+ ['nigerian', 'NGN'],
+ ['uganda', 'UGX'],
+ ['UGX', 'UGX'],
+ ['tanzania', 'TZS'],
+ ['tanzanian shilling', 'TZS'],
+ ])('reads %s', (text, currency) => {
+ expect(parseCurrencyAnswer(text)).toEqual({ currency })
+ })
+
+ it.each([
+ ['ghana', 'GHS'],
+ ['USD', 'USD'],
+ ['euros', 'EUR'],
+ ])('says %s is a currency it cannot pay', (text, unsupported) => {
+ expect(parseCurrencyAnswer(text)).toEqual({ unsupported })
+ })
+
+ it.each(['shillings', 'swap 5 xlm to usdc', 'what can you do', 'yes', ''])(
+ 'does not read %j as an answer',
+ (text) => {
+ expect(parseCurrencyAnswer(text)).toBeNull()
+ }
+ )
+})
+
+describe('parseCashout', () => {
+ it.each([
+ ['cash out 25 usdc', { amount: '25', token: 'USDC' }],
+ ['Cash out 25 USDC.', { amount: '25', token: 'USDC' }],
+ ['please cash out 25 usdc', { amount: '25', token: 'USDC' }],
+ ['cash out 25 usdc to kes', { amount: '25', token: 'USDC', currency: 'KES' }],
+ ['cashout 25 usdc to kes', { amount: '25', token: 'USDC', currency: 'KES' }],
+ ['cash-out 25 usdc to kes', { amount: '25', token: 'USDC', currency: 'KES' }],
+ ['withdraw 25 usdc to kenya', { amount: '25', token: 'USDC', currency: 'KES' }],
+ ['off-ramp 10 usdc to uganda', { amount: '10', token: 'USDC', currency: 'UGX' }],
+ ['offramp 10 usdc to ugx', { amount: '10', token: 'USDC', currency: 'UGX' }],
+ ['sell 25 usdc for naira', { amount: '25', token: 'USDC', currency: 'NGN' }],
+ ['cash out 25 usdc to nigeria', { amount: '25', token: 'USDC', currency: 'NGN' }],
+ ['cash out 25 usdc to tanzanian shillings', { amount: '25', token: 'USDC', currency: 'TZS' }],
+ ['cash out to tanzania', { token: 'USDC', currency: 'TZS' }],
+ ['cash out', { token: 'USDC' }],
+ ['cash out my usdc to my kes', { token: 'USDC', currency: 'KES' }],
+ ['cash out usdc', { token: 'USDC' }],
+ ['cash out $25', { amount: '25', token: 'USDC' }],
+ ['cash out $25 to kes', { amount: '25', token: 'USDC', currency: 'KES' }],
+ ])('reads %s', (text, expected) => {
+ expect(parseCashout(text)).toEqual(expected)
+ })
+
+ it.each([
+ ['offramp4 usd', { amount: '4', token: 'USDC' }],
+ ['offramp 4 usd', { amount: '4', token: 'USDC' }],
+ ['off-ramp4 usdc', { amount: '4', token: 'USDC' }],
+ ['cash out4 usdc to kes', { amount: '4', token: 'USDC', currency: 'KES' }],
+ ['offramp$4', { amount: '4', token: 'USDC' }],
+ ['cash out 25 dollars', { amount: '25', token: 'USDC' }],
+ ['cash out 1 dollar', { amount: '1', token: 'USDC' }],
+ ['cash out 25 usd to kes', { amount: '25', token: 'USDC', currency: 'KES' }],
+ ])('reads %s, where a dollar amount means USDC', (text, expected) => {
+ expect(parseCashout(text)).toEqual(expected)
+ })
+
+ it.each([
+ ['cash out 3 usdc to my bank', { amount: '3', token: 'USDC' }],
+ ['withdraw 3 usdc to my bank account', { amount: '3', token: 'USDC' }],
+ ['off-ramp 3 usdc to fiat', { amount: '3', token: 'USDC' }],
+ ['cash out usdc to my bank', { token: 'USDC' }],
+ ])('reads %s as naming no currency', (text, expected) => {
+ expect(parseCashout(text)).toEqual(expected)
+ })
+
+ it.each([
+ ['cash out 1,000 usdc', '1000'],
+ ['cash out 1,250,000.50 usdc', '1250000.50'],
+ ['cash out .5 usdc', '0.5'],
+ ['cash out 0.25 usdc', '0.25'],
+ ])('reads the amount in %s', (text, amount) => {
+ expect(parseCashout(text)).toEqual({ amount, token: 'USDC' })
+ })
+
+ it.each([
+ ['cash out 5 sol', 'SOL'],
+ ['withdraw 5 xlm to kenya', 'XLM'],
+ ['cash out sol', 'SOL'],
+ ['cash out 5 usdc.e', 'USDC.E'],
+ ['cash out 5 usdt', 'USDT'],
+ ])('names the other token in %s', (text, token) => {
+ expect(parseCashout(text)).toMatchObject({ unsupportedToken: token })
+ })
+
+ it('keeps the currency beside an unsupported token', () => {
+ expect(parseCashout('cash out 5 xlm to kes')).toEqual({
+ unsupportedToken: 'XLM',
+ currency: 'KES',
+ })
+ expect(parseCashout('cash out 5 sol')).toEqual({ unsupportedToken: 'SOL' })
+ })
+
+ it.each([
+ ['cash out 25 usdc to brl', 'BRL'],
+ ['cash out to brazil', 'BRL'],
+ ['withdraw 25 usdc to ghana', 'GHS'],
+ ['sell 25 usdc for euros', 'EUR'],
+ ['cash out 25 usdc to usd', 'USD'],
+ ])('refuses the currency in %s rather than guessing', (text, currency) => {
+ expect(parseCashout(text)).toEqual({ unsupportedCurrency: currency })
+ })
+
+ it.each([
+ 'swap 25 usdc to sol',
+ 'send 25 usdc to bob',
+ 'cash out 25 usdc to bob',
+ 'withdraw 25 usdc to bob',
+ 'sell 25 usdc for sol',
+ 'cash out 25 usdc to sol',
+ 'cash out 25 usdc to kes and swap the rest',
+ 'cash out 25 usdc to shillings',
+ 'how do i cash out',
+ 'what is a cash out',
+ 'cash outs',
+ 'cash out now',
+ 'cash out everything',
+ 'cash out all my usdc',
+ 'cash out 25',
+ 'cash out 25 to kes',
+ 'cash out 5000 naira',
+ 'cash out usd',
+ 'cash out dollars',
+ 'offramping 4 usdc',
+ 'cash outfit',
+ 'cash out kes',
+ 'cash out 0 usdc',
+ 'cash out 1,00 usdc',
+ 'cash out 1.2.3 usdc',
+ 'cash out -5 usdc',
+ 'withdraw',
+ 'withdraw 25 usdc',
+ 'sell',
+ 'sell 25 usdc',
+ 'sell 25 sol',
+ '',
+ ])('leaves %j alone', (text) => {
+ expect(parseCashout(text)).toBeNull()
+ })
+})
+
+describe('routeCashout', () => {
+ const read = (text: string): ReturnType => parseCashout(text)
+
+ it.each(['near', 'solana', 'sui', 'avalanche', 'stellar'])(
+ 'asks which currency on %s when none is named',
+ (chain) => {
+ expect(routeCashout(read('off-ramp 3 USDC'), chain)).toEqual({ kind: 'ask', amount: '3' })
+ expect(routeCashout(read('cash out'), chain)).toEqual({ kind: 'ask' })
+ expect(routeCashout(read('cash out 3 usdc to my bank'), chain)).toEqual({
+ kind: 'ask',
+ amount: '3',
+ })
+ }
+ )
+
+ it('keeps the test anchor flow on stellar testnet, where no real cash-out runs', () => {
+ expect(routeCashout(read('off-ramp 3 USDC'), 'stellar', false)).toEqual({ kind: 'pass' })
+ })
+
+ it('still asks on other chains when the page is on stellar testnet', () => {
+ expect(routeCashout(read('off-ramp 3 USDC'), 'sui', false)).toEqual({
+ kind: 'ask',
+ amount: '3',
+ })
+ })
+
+ it.each(['near', 'solana', 'sui', 'avalanche'])('opens the card on %s', (chain) => {
+ expect(routeCashout(read('cash out 25 usdc to kes'), chain)).toEqual({
+ kind: 'open',
+ amount: '25',
+ currency: 'KES',
+ })
+ expect(routeCashout(read('sell 25 usdc for naira'), chain)).toEqual({
+ kind: 'open',
+ amount: '25',
+ currency: 'NGN',
+ })
+ })
+
+ it.each(['near', 'solana', 'sui', 'avalanche'])('answers refusals on %s', (chain) => {
+ expect(routeCashout(read('cash out 5 sol'), chain)).toEqual({ kind: 'token', token: 'SOL' })
+ expect(routeCashout(read('cash out 5 usdc to brl'), chain)).toEqual({
+ kind: 'currency',
+ currency: 'BRL',
+ })
+ })
+
+ it.each(['KES', 'UGX', 'TZS'])('opens the card on stellar for %s', (currency) => {
+ expect(routeCashout(read(`cash out 25 usdc to ${currency}`), 'stellar')).toEqual({
+ kind: 'open',
+ amount: '25',
+ currency,
+ })
+ })
+
+ it.each([
+ 'cash out 25 usdc to ngn',
+ 'sell 25 usdc for naira',
+ 'cash out 5 xlm',
+ 'cash out 5 xlm to naira',
+ 'cash out 25 usdc to brl',
+ ])('leaves %s to the existing path on stellar', (text) => {
+ expect(routeCashout(read(text), 'stellar')).toEqual({ kind: 'pass' })
+ })
+
+ it('tells stellar a Weave currency takes USDC', () => {
+ expect(routeCashout(read('cash out 5 xlm to kes'), 'stellar')).toEqual({
+ kind: 'token',
+ token: 'XLM',
+ })
+ })
+
+ it.each(['monad', 'arc'])('changes nothing on %s', (chain) => {
+ expect(routeCashout(read('cash out 25 usdc to kes'), chain)).toEqual({ kind: 'pass' })
+ expect(routeCashout(read('cash out 5 sol'), chain)).toEqual({ kind: 'pass' })
+ })
+
+ it('passes what was not a cash-out', () => {
+ expect(routeCashout(null, 'near')).toEqual({ kind: 'pass' })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-pay-route.test.ts b/apps/dapp/lib/__tests__/weave-pay-route.test.ts
new file mode 100644
index 00000000..c45e0a53
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-pay-route.test.ts
@@ -0,0 +1,486 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+ mainnet: true,
+ weave: {} as Record,
+ repo: {} as Record,
+ deposit: {} as Record,
+ report: [] as unknown[][],
+ logged: [] as unknown[],
+}))
+
+vi.mock('@intent/config', async (original) => ({
+ ...(await original()),
+ isMainnet: () => state.mainnet,
+}))
+vi.mock('../weave/client', async (original) => ({
+ ...(await original()),
+ createWeaveClient: () => state.weave,
+}))
+vi.mock('../weave/orders', async (original) => ({
+ ...(await original()),
+ getWeaveOrderRepo: async () => state.repo,
+}))
+vi.mock('../weave/deposit', () => ({ depositChain: () => state.deposit }))
+vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined }))
+vi.mock('../server/report', () => ({
+ reportError: (...args: unknown[]) => state.report.push(args),
+}))
+vi.mock('../server/analytics', () => ({
+ logExecution: async (input: unknown) => {
+ state.logged.push(input)
+ },
+}))
+
+import { POST as build } from '../../app/api/cashout/orders/[id]/build/route'
+import { maxDuration, POST as submit } from '../../app/api/cashout/orders/[id]/submit/route'
+import { ChainActionRefused } from '../chains/types'
+import { WALLET_HEADER } from '../wallet-header'
+
+const ID = '3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456'
+const DIGITS = '254700001234'
+const HOLDER = 'JANE ADEYEMI'
+const MEMO = 'MEMO99'
+const context = { params: Promise.resolve({ id: ID }) }
+
+function row(over: Record = {}): Record {
+ return {
+ id: ID,
+ chain: 'solana',
+ email: 'wallet:alice',
+ account: 'alice',
+ providerOrder: 'ord_1',
+ idempotencyKey: 'key',
+ currency: 'KES',
+ institutionCode: 'MPESA',
+ institutionName: 'M-Pesa',
+ accountIdentifier: DIGITS,
+ accountName: HOLDER,
+ depositAddress: 'weaveDeposit',
+ depositMemo: MEMO,
+ amount: '25.5',
+ estimatedDest: '3200.5',
+ expiresAt: '2999-01-01T00:00:00.000Z',
+ status: 'waiting',
+ providerStatus: 'awaiting_deposit',
+ depositHash: undefined,
+ createdAt: '2026-11-04T11:00:00.000Z',
+ ...over,
+ }
+}
+
+const EXPECTATION = {
+ account: 'alice',
+ depositAddress: 'weaveDeposit',
+ memo: MEMO,
+ amount: '25.5',
+}
+
+function req(path: string, payload?: unknown): Request {
+ return new Request(`http://localhost/api/cashout/orders/${ID}/${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', [WALLET_HEADER]: 'alice.near' },
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
+ })
+}
+
+const SIGNED = { family: 'solana', signedTransaction: 'c2lnbmVk' }
+const SIGNING = { family: 'solana', transaction: 'dHg=' }
+
+const fn = (value: unknown): ReturnType => value as ReturnType
+
+function live(status: string): void {
+ state.weave = {
+ configured: true,
+ getOrder: vi.fn(async () => ({ ok: true, value: { id: 'ord_1', status } })),
+ }
+}
+
+function stored(over: Record = {}): void {
+ state.repo = {
+ getForEmail: vi.fn(async () => row(over)),
+ setStatus: vi.fn(async () => undefined),
+ setDepositHash: vi.fn(async () => 'stored'),
+ claimSend: vi.fn(async () => true),
+ releaseSend: vi.fn(async () => undefined),
+ }
+}
+
+beforeEach(() => {
+ state.mainnet = true
+ state.report = []
+ state.logged = []
+ live('awaiting_deposit')
+ stored()
+ state.deposit = {
+ build: vi.fn(async () => SIGNING),
+ assertSafe: vi.fn(),
+ submit: vi.fn(async () => ({ ok: true, hash: 'hash1', explorerUrl: 'https://x/hash1' })),
+ }
+})
+
+const refusals: [string, () => void, number][] = [
+ ['now processing at Weave', () => live('processing'), 409],
+ ['completed at Weave', () => live('completed'), 409],
+ ['expired at Weave', () => live('expired'), 409],
+ ['in a state Weave has not documented', () => live('settling'), 503],
+ ['stored as already paid into', () => stored({ depositHash: 'earlier' }), 409],
+ ['past its expiry', () => stored({ expiresAt: '2020-01-01T00:00:00.000Z' }), 409],
+ ['stored as failed', () => stored({ status: 'failed', providerStatus: 'failed' }), 409],
+ [
+ 'one Weave cannot be asked about',
+ () => {
+ state.weave = {
+ configured: true,
+ getOrder: vi.fn(async () => ({ ok: false, code: 'unavailable', message: 'x' })),
+ }
+ },
+ 503,
+ ],
+]
+
+describe('POST build', () => {
+ it('builds from the row alone and checks the payload before returning it', async () => {
+ const res = await build(req('build', { amount: '9999', depositAddress: 'evil' }), context)
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ signing: SIGNING })
+ expect(fn(state.deposit['build'])).toHaveBeenCalledWith(EXPECTATION)
+ expect(fn(state.deposit['assertSafe'])).toHaveBeenCalledWith(SIGNING, EXPECTATION)
+ })
+
+ it('reads the order scoped to this requester', async () => {
+ await build(req('build'), context)
+ expect(fn(state.repo['getForEmail'])).toHaveBeenCalledWith(ID, 'wallet:alice.near')
+ })
+
+ it("answers not found for a wrong id or somebody else's", async () => {
+ state.repo = { getForEmail: vi.fn(async () => undefined) }
+ expect((await build(req('build'), context)).status).toBe(404)
+ expect(fn(state.deposit['build'])).not.toHaveBeenCalled()
+ })
+
+ it('refuses a Stellar order off mainnet', async () => {
+ state.mainnet = false
+ stored({ chain: 'stellar' })
+ expect((await build(req('build'), context)).status).toBe(403)
+ expect(fn(state.deposit['build'])).not.toHaveBeenCalled()
+ })
+
+ it.each(refusals)('refuses an order %s', async (_name, arrange, status) => {
+ arrange()
+ const res = await build(req('build'), context)
+ expect(res.status).toBe(status)
+ expect((await res.json()).message).toMatch(/\./)
+ expect(fn(state.deposit['build'])).not.toHaveBeenCalled()
+ })
+
+ it('returns nothing to sign when the check on the payload refuses it', async () => {
+ fn(state.deposit['assertSafe']).mockImplementation(() => {
+ throw new ChainActionRefused('This transaction does something else.')
+ })
+ const res = await build(req('build'), context)
+ expect(res.status).toBe(422)
+ const json = await res.json()
+ expect(json.signing).toBeUndefined()
+ expect(json.message).toBe('This transaction does something else.')
+ })
+
+ it('says nothing was signed when the build fails, and reports no details', async () => {
+ fn(state.deposit['build']).mockRejectedValue(new Error(`rpc ${MEMO} ${DIGITS} ${HOLDER}`))
+ const res = await build(req('build'), context)
+ expect(res.status).toBe(502)
+ const text = JSON.stringify(await res.json())
+ expect(text).toMatch(/nothing was signed or sent/i)
+ expect(text).not.toContain(MEMO)
+ const report = JSON.stringify(state.report.map((r) => [(r[1] as Error).message, r[2]]))
+ expect(report).not.toMatch(new RegExp(`${MEMO}|${DIGITS}|${HOLDER}`))
+ })
+})
+
+describe('POST submit', () => {
+ it('sends against the row, stores the hash and logs an off-ramp', async () => {
+ const res = await submit(req('submit', { signed: SIGNED, amount: '9999' }), context)
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ ok: true, hash: 'hash1', explorerUrl: 'https://x/hash1' })
+ expect(fn(state.deposit['submit'])).toHaveBeenCalledWith(SIGNED, EXPECTATION)
+ expect(fn(state.repo['setDepositHash'])).toHaveBeenCalledWith(ID, 'hash1')
+ expect(state.logged[0]).toMatchObject({
+ kind: 'offramp',
+ account: 'alice',
+ network: 'mainnet',
+ feeSponsored: false,
+ })
+ })
+
+ it('moves no status itself', async () => {
+ await submit(req('submit', { signed: SIGNED }), context)
+ expect(fn(state.repo['setStatus'])).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['nothing', undefined],
+ ['an unknown family', { signed: { family: 'bitcoin', tx: 'x' } }],
+ ['a solana payload with no bytes', { signed: { family: 'solana', signedTransaction: '' } }],
+ ['a sui payload with no signature', { signed: { family: 'sui', transaction: 'x' } }],
+ ])('refuses %s', async (_name, payload) => {
+ expect((await submit(req('submit', payload), context)).status).toBe(400)
+ expect(fn(state.deposit['submit'])).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['solana', { family: 'near', hashes: ['h'] }],
+ ['near', { family: 'solana', signedTransaction: 'x' }],
+ ['sui', { family: 'evm', hash: '0xabc' }],
+ ['stellar', { family: 'solana', signedTransaction: 'x' }],
+ ['avalanche', { family: 'stellar', signedXdr: 'x' }],
+ ['avalanche', { family: 'avalanche', hash: '0xabc' }],
+ ])('refuses a %s order signed by another family', async (chain, signed) => {
+ stored({ chain })
+ const res = await submit(req('submit', { signed }), context)
+ expect(res.status).toBe(400)
+ expect(fn(state.deposit['submit'])).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['stellar', { family: 'stellar', signedXdr: 'AAAA' }],
+ ['solana', SIGNED],
+ ['sui', { family: 'sui', transaction: 'x', signature: 'y' }],
+ ['near', { family: 'near', hashes: ['h'] }],
+ ['avalanche', { family: 'evm', hash: '0xabc' }],
+ ])('accepts a %s order signed by its own family', async (chain, signed) => {
+ stored({ chain })
+ expect((await submit(req('submit', { signed }), context)).status).toBe(200)
+ expect(fn(state.deposit['submit'])).toHaveBeenCalledWith(signed, EXPECTATION)
+ })
+
+ it('passes a Stellar envelope to the log, so its volume can be read', async () => {
+ stored({ chain: 'stellar' })
+ await submit(req('submit', { signed: { family: 'stellar', signedXdr: 'AAAA' } }), context)
+ expect(state.logged[0]).toMatchObject({ signedXdr: 'AAAA' })
+ })
+
+ it.each(refusals)('refuses an order %s', async (_name, arrange, status) => {
+ arrange()
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(status)
+ expect(fn(state.deposit['submit'])).not.toHaveBeenCalled()
+ expect((await res.json()).error).toMatch(/nothing (more )?was sent/i)
+ })
+
+ it('does not claim nothing was sent when the wallet sends for itself', async () => {
+ stored({ chain: 'near', depositHash: 'earlier' })
+ const res = await submit(req('submit', { signed: { family: 'near', hashes: ['h'] } }), context)
+ expect(res.status).toBe(409)
+ const { error: message } = await res.json()
+ expect(message).not.toMatch(/nothing (more )?was sent/i)
+ expect(message).toMatch(/do not send it again/i)
+ })
+
+ it.each([
+ ['near', { family: 'near', hashes: ['h'] }],
+ ['avalanche', { family: 'evm', hash: '0xabc' }],
+ ])(
+ 'records a %s transfer Weave has already seen, because the wallet has already sent it',
+ async (chain, signed) => {
+ stored({ chain })
+ live('processing')
+ const res = await submit(req('submit', { signed }), context)
+ expect(res.status).toBe(200)
+ expect(fn(state.repo['setDepositHash'])).toHaveBeenCalledWith(ID, 'hash1')
+ }
+ )
+
+ describe('the send claim', () => {
+ it('is taken before the chain is asked to send', async () => {
+ const order: string[] = []
+ fn(state.repo['claimSend']).mockImplementation(async () => {
+ order.push('claim')
+ return true
+ })
+ fn(state.deposit['submit']).mockImplementation(async () => {
+ order.push('send')
+ return { ok: true, hash: 'hash1', explorerUrl: 'https://x/hash1' }
+ })
+ await submit(req('submit', { signed: SIGNED }), context)
+ expect(order).toEqual(['claim', 'send'])
+ })
+
+ it('stops a second send and says not to send again', async () => {
+ fn(state.repo['claimSend']).mockResolvedValue(false)
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(409)
+ expect(fn(state.deposit['submit'])).not.toHaveBeenCalled()
+ const json = await res.json()
+ expect(json).toMatchObject({ ok: false, pending: true, code: 'send_started' })
+ expect(json.error).toMatch(/do not send/i)
+ })
+
+ it('refuses to send when the claim cannot be recorded', async () => {
+ fn(state.repo['claimSend']).mockRejectedValue(new Error('db down'))
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(503)
+ expect(fn(state.deposit['submit'])).not.toHaveBeenCalled()
+ expect((await res.json()).error).toMatch(/nothing was sent/i)
+ })
+
+ it('still records a wallet-sent transfer when the claim cannot be recorded', async () => {
+ stored({ chain: 'avalanche' })
+ fn(state.repo['claimSend']).mockRejectedValue(new Error('db down'))
+ const res = await submit(req('submit', { signed: { family: 'evm', hash: '0xabc' } }), context)
+ expect(res.status).toBe(200)
+ expect(fn(state.deposit['submit'])).toHaveBeenCalled()
+ })
+
+ it('is released when the network definitely refused the send', async () => {
+ fn(state.deposit['submit']).mockResolvedValue({ ok: false, error: 'The network refused it.' })
+ await submit(req('submit', { signed: SIGNED }), context)
+ expect(fn(state.repo['releaseSend'])).toHaveBeenCalledWith(ID)
+ })
+
+ it('is released when the signed bytes were refused before sending', async () => {
+ fn(state.deposit['submit']).mockRejectedValue(new ChainActionRefused('Wrong amount.'))
+ await submit(req('submit', { signed: SIGNED }), context)
+ expect(fn(state.repo['releaseSend'])).toHaveBeenCalledWith(ID)
+ })
+
+ it.each([
+ ['a good send', async () => ({ ok: true, hash: 'hash1', explorerUrl: 'https://x/hash1' })],
+ ['a pending send', async () => ({ ok: false, pending: true, error: 'Sent.' })],
+ [
+ 'a send that threw',
+ async () => {
+ throw new Error('rpc died')
+ },
+ ],
+ ])('is kept after %s, because the money may have moved', async (_name, outcome) => {
+ fn(state.deposit['submit']).mockImplementation(outcome)
+ await submit(req('submit', { signed: SIGNED }), context)
+ expect(fn(state.repo['releaseSend'])).not.toHaveBeenCalled()
+ })
+
+ it("does not release another request's claim", async () => {
+ fn(state.repo['claimSend']).mockResolvedValue(false)
+ stored({ chain: 'avalanche' })
+ fn(state.repo['claimSend']).mockResolvedValue(false)
+ fn(state.deposit['submit']).mockResolvedValue({ ok: false, error: 'Not this deposit.' })
+ await submit(req('submit', { signed: { family: 'evm', hash: '0xforged' } }), context)
+ expect(fn(state.repo['releaseSend'])).not.toHaveBeenCalled()
+ })
+
+ it('is released when a wallet-sent hash was already counted elsewhere', async () => {
+ stored({ chain: 'avalanche' })
+ fn(state.repo['setDepositHash']).mockResolvedValue('reused')
+ fn(state.deposit['submit']).mockResolvedValue({
+ ok: true,
+ hash: '0xold',
+ explorerUrl: 'https://x/0xold',
+ })
+ await submit(req('submit', { signed: { family: 'evm', hash: '0xold' } }), context)
+ expect(fn(state.repo['releaseSend'])).toHaveBeenCalledWith(ID)
+ })
+
+ it('stops a new deposit being built while a send is unresolved', async () => {
+ stored({ sendStarted: true })
+ const res = await build(req('build'), context)
+ expect(res.status).toBe(409)
+ expect(fn(state.deposit['build'])).not.toHaveBeenCalled()
+ const { error, message } = await res.json()
+ expect(error).toBe('send_started')
+ expect(message).toMatch(/do not send/i)
+ expect(message).not.toMatch(/nothing (more )?was sent/i)
+ })
+ })
+
+ it('allows the longest confirmation a chain can poll for', () => {
+ expect(maxDuration).toBe(60)
+ })
+
+ it('refuses a second submit once a hash is stored, without calling the chain', async () => {
+ stored({ chain: 'avalanche', depositHash: '0xabc' })
+ const res = await submit(req('submit', { signed: { family: 'evm', hash: '0xabc' } }), context)
+ expect(res.status).toBe(409)
+ expect(fn(state.deposit['submit'])).not.toHaveBeenCalled()
+ expect(fn(state.repo['setDepositHash'])).not.toHaveBeenCalled()
+ })
+
+ it('refuses a hash already counted, rather than answering that it paid this order', async () => {
+ stored({ chain: 'avalanche' })
+ fn(state.repo['setDepositHash']).mockResolvedValue('reused')
+ fn(state.deposit['submit']).mockResolvedValue({
+ ok: true,
+ hash: '0xold',
+ explorerUrl: 'https://x/0xold',
+ })
+ const res = await submit(req('submit', { signed: { family: 'evm', hash: '0xold' } }), context)
+ expect(res.status).toBe(409)
+ const json = await res.json()
+ expect(json).toMatchObject({ ok: false, code: 'hash_reused' })
+ expect(json.hash).toBeUndefined()
+ expect(json.error).toMatch(/do not send it again/i)
+ expect(state.logged[0]).toMatchObject({ result: { ok: false, reason: 'hash_reused' } })
+ expect(state.report).toHaveLength(1)
+ })
+
+ it('passes on the wording of a pending result, and moves no status', async () => {
+ const error = "Your USDC deposit was sent (abc) but isn't confirmed yet. Don't send it again."
+ fn(state.deposit['submit']).mockResolvedValue({ ok: false, error, pending: true })
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(202)
+ const json = await res.json()
+ expect(json).toEqual({ ok: false, pending: true, error })
+ expect(fn(state.repo['setStatus'])).not.toHaveBeenCalled()
+ expect(fn(state.repo['setDepositHash'])).not.toHaveBeenCalled()
+ expect(state.logged[0]).toMatchObject({ result: { reason: 'pending' } })
+ })
+
+ it('stores the hash of a pending transfer when there is one', async () => {
+ fn(state.deposit['submit']).mockResolvedValue({
+ ok: false,
+ error: 'Sent.',
+ pending: true,
+ hash: 'hash2',
+ })
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(202)
+ expect(fn(state.repo['setDepositHash'])).toHaveBeenCalledWith(ID, 'hash2')
+ expect((await res.json()).hash).toBe('hash2')
+ })
+
+ it('passes on a refusal from the network and stores nothing', async () => {
+ fn(state.deposit['submit']).mockResolvedValue({ ok: false, error: 'The network refused it.' })
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(422)
+ expect((await res.json()).error).toBe('The network refused it.')
+ expect(fn(state.repo['setDepositHash'])).not.toHaveBeenCalled()
+ })
+
+ it('says a check that refused the bytes before sending', async () => {
+ fn(state.deposit['submit']).mockRejectedValue(new ChainActionRefused('Wrong amount.'))
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(422)
+ expect((await res.json()).error).toBe('Wrong amount.')
+ })
+
+ it('says to check the wallet when sending throws, because the outcome is unknown', async () => {
+ fn(state.deposit['submit']).mockRejectedValue(new Error(`rpc died ${MEMO} ${DIGITS} ${HOLDER}`))
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(502)
+ const answer = await res.json()
+ const text = JSON.stringify(answer)
+ expect(text).toMatch(/check your wallet/i)
+ expect(text).not.toContain('rpc died')
+ expect(answer).toMatchObject({ ok: false, pending: true })
+ const report = JSON.stringify(state.report.map((r) => [(r[1] as Error).message, r[2]]))
+ expect(report).toContain('rpc died')
+ expect(report).not.toMatch(new RegExp(`${MEMO}|${DIGITS}|${HOLDER}`))
+ })
+
+ it('still reports success when the transfer landed and only the record failed', async () => {
+ stored()
+ fn(state.repo['setDepositHash']).mockRejectedValue(new Error('db down'))
+ const res = await submit(req('submit', { signed: SIGNED }), context)
+ expect(res.status).toBe(200)
+ expect((await res.json()).hash).toBe('hash1')
+ expect(state.report).toHaveLength(1)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-status.test.ts b/apps/dapp/lib/__tests__/weave-status.test.ts
new file mode 100644
index 00000000..95b66d0a
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-status.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+
+import { isFinal, mapProviderStatus, nextStatus } from '../weave/status'
+
+describe('mapProviderStatus', () => {
+ it.each([
+ ['awaiting_deposit', 'waiting'],
+ ['processing', 'processing'],
+ ['completed', 'paid'],
+ ['failed', 'failed'],
+ ['expired', 'expired'],
+ ['refunded', 'refunded'],
+ [' COMPLETED ', 'paid'],
+ ])('reads %s as %s', (provider, ours) => {
+ expect(mapProviderStatus(provider)).toBe(ours)
+ })
+
+ it('does not guess at a string it does not know', () => {
+ expect(mapProviderStatus('settling')).toBeUndefined()
+ expect(mapProviderStatus('')).toBeUndefined()
+ })
+})
+
+describe('nextStatus', () => {
+ it('moves to what the provider says', () => {
+ expect(nextStatus('waiting', 'processing')).toBe('processing')
+ expect(nextStatus('processing', 'completed')).toBe('paid')
+ })
+
+ it('stays at the current status for an unknown string', () => {
+ expect(nextStatus('waiting', 'settling')).toBe('waiting')
+ expect(nextStatus('processing', 'settling')).toBe('processing')
+ })
+
+ it.each(['paid', 'failed', 'expired', 'refunded'] as const)('never moves %s', (final) => {
+ expect(nextStatus(final, 'processing')).toBe(final)
+ expect(nextStatus(final, 'awaiting_deposit')).toBe(final)
+ expect(nextStatus(final, 'completed')).toBe(final)
+ })
+
+ it('never goes back to waiting, which would make a funded order payable again', () => {
+ expect(nextStatus('processing', 'awaiting_deposit')).toBe('processing')
+ })
+})
+
+describe('isFinal', () => {
+ it('is true only for states that cannot change', () => {
+ expect(isFinal('paid')).toBe(true)
+ expect(isFinal('failed')).toBe(true)
+ expect(isFinal('expired')).toBe(true)
+ expect(isFinal('refunded')).toBe(true)
+ expect(isFinal('waiting')).toBe(false)
+ expect(isFinal('processing')).toBe(false)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/weave-webhook.test.ts b/apps/dapp/lib/__tests__/weave-webhook.test.ts
new file mode 100644
index 00000000..5cfb17d0
--- /dev/null
+++ b/apps/dapp/lib/__tests__/weave-webhook.test.ts
@@ -0,0 +1,68 @@
+import { createHmac } from 'node:crypto'
+
+import { describe, expect, it } from 'vitest'
+
+import { verifyWeaveSignature } from '../weave/webhook'
+
+const SECRET = 'whsec_test'
+const NOW = 1_800_000_000
+const BODY = '{"event":"order.completed","data":{"id":"ord_1"}}'
+
+function sign(body: string | Buffer, timestamp: string, secret = SECRET): string {
+ return 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')
+}
+
+describe('verifyWeaveSignature', () => {
+ it('accepts a fresh signature over timestamp and body', () => {
+ const ts = String(NOW - 10)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts), ts, SECRET, NOW)).toBe(true)
+ })
+
+ it('signs the raw bytes, so bytes that are not valid text still verify', () => {
+ const raw = Buffer.from([0x7b, 0xff, 0xfe, 0x7d])
+ const ts = String(NOW)
+ expect(verifyWeaveSignature(raw, sign(raw, ts), ts, SECRET, NOW)).toBe(true)
+ })
+
+ it('refuses a changed body', () => {
+ const ts = String(NOW)
+ expect(verifyWeaveSignature(`${BODY} `, sign(BODY, ts), ts, SECRET, NOW)).toBe(false)
+ })
+
+ it('refuses a timestamp the signature does not cover', () => {
+ const ts = String(NOW)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts), String(NOW - 1), SECRET, NOW)).toBe(false)
+ })
+
+ it('refuses a signature older than five minutes', () => {
+ const ts = String(NOW - 301)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts), ts, SECRET, NOW)).toBe(false)
+ const edge = String(NOW - 300)
+ expect(verifyWeaveSignature(BODY, sign(BODY, edge), edge, SECRET, NOW)).toBe(true)
+ })
+
+ it('refuses a timestamp far in the future', () => {
+ const ts = String(NOW + 301)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts), ts, SECRET, NOW)).toBe(false)
+ })
+
+ it('fails closed with no secret, even for a signature made with an empty one', () => {
+ const ts = String(NOW)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts, ''), ts, '', NOW)).toBe(false)
+ })
+
+ it('refuses missing or malformed headers', () => {
+ const ts = String(NOW)
+ expect(verifyWeaveSignature(BODY, null, ts, SECRET, NOW)).toBe(false)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts), null, SECRET, NOW)).toBe(false)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts).slice(7), ts, SECRET, NOW)).toBe(false)
+ expect(verifyWeaveSignature(BODY, 'sha256=abc', ts, SECRET, NOW)).toBe(false)
+ const soft = `${NOW}.5`
+ expect(verifyWeaveSignature(BODY, sign(BODY, soft), soft, SECRET, NOW)).toBe(false)
+ })
+
+ it('refuses a signature made with another secret', () => {
+ const ts = String(NOW)
+ expect(verifyWeaveSignature(BODY, sign(BODY, ts, 'other'), ts, SECRET, NOW)).toBe(false)
+ })
+})
diff --git a/apps/dapp/lib/adapters/arc-adapter.ts b/apps/dapp/lib/adapters/arc-adapter.ts
deleted file mode 100644
index 24e40f1a..00000000
--- a/apps/dapp/lib/adapters/arc-adapter.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-'use client'
-
-import { accountExplorerUrl, arcDescriptor } from '@intent/config'
-import { useAccount, useBalance, useChainId, useConnect, useDisconnect, useSwitchChain } from 'wagmi'
-
-import type { ChainAdapter, ChainWallet } from '../chain-adapter'
-import { arcTestnet } from '../wagmi.config'
-
-/**
- * Arc/EVM wallet, behind the shared adapter interface.
- *
- * Balance is the *native* USDC view (18 decimals), which is what the wallet
- * shows. Reading USDC through its ERC-20 interface gives 6 decimals for the
- * same balance — see `USDC_ERC20_DECIMALS` in `@intent/config`.
- */
-function useArcWallet(): ChainWallet {
- const { address, isConnected, isConnecting, isReconnecting } = useAccount()
- const chainId = useChainId()
- const { switchChain, isPending: isSwitching } = useSwitchChain()
- const { connect, connectors, error: connectError } = useConnect()
- const { disconnect } = useDisconnect()
-
- const isWrongNetwork = isConnected && chainId !== arcTestnet.id
-
- const { data: balanceData } = useBalance({
- address,
- chainId: arcTestnet.id,
- query: { enabled: isConnected && !isWrongNetwork },
- })
-
- return {
- address,
- isConnected,
- isConnecting: isConnecting || isReconnecting,
- isWrongNetwork,
- balance: balanceData?.formatted,
- balanceSymbol: balanceData?.symbol ?? arcDescriptor.nativeCurrency.symbol,
- // Compared against BigInt(0) rather than a 0n literal: the TS target
- // predates BigInt literal syntax.
- needsFunding:
- isConnected &&
- !isWrongNetwork &&
- balanceData !== undefined &&
- balanceData.value === BigInt(0),
- // An injected connector is always registered, so the browser-extension
- // check happens at connect time rather than here.
- isWalletUnavailable: false,
- error: connectError?.message,
- connect: () => {
- const injected = connectors[0]
- if (injected) connect({ connector: injected })
- },
- disconnect: () => disconnect(),
- switchNetwork: () => switchChain({ chainId: arcTestnet.id }),
- isSwitching,
- }
-}
-
-export const arcAdapter: ChainAdapter = {
- descriptor: arcDescriptor,
- useWallet: useArcWallet,
- accountUrl: (address) => accountExplorerUrl('arc', address),
-}
diff --git a/apps/dapp/lib/adapters/evm-adapter.ts b/apps/dapp/lib/adapters/evm-adapter.ts
new file mode 100644
index 00000000..0162dc78
--- /dev/null
+++ b/apps/dapp/lib/adapters/evm-adapter.ts
@@ -0,0 +1,133 @@
+'use client'
+
+import {
+ accountExplorerUrl,
+ arcDescriptor,
+ avalancheDescriptor,
+ monadDescriptor,
+} from '@intent/config'
+import type { ChainDescriptor } from '@intent/types'
+import { BaseError, UserRejectedRequestError, type Address, type Hex } from 'viem'
+import { useAccount, useBalance, useConnect, useDisconnect, useSwitchChain } from 'wagmi'
+import { getAccount, sendTransaction, switchChain } from 'wagmi/actions'
+import { avalanche, monad } from 'viem/chains'
+
+import type { ChainAdapter, ChainWallet } from '../chain-adapter'
+import type { SignedOutcome, SigningPayload } from '../chains/types'
+import { arcTestnet, wagmiConfig } from '../wagmi.config'
+import { isUserRejection } from '../wallet/connector'
+import { walletErrorMessage } from '../wallet/error-message'
+
+/**
+ * An EVM chain's wallet, behind the shared adapter interface. wagmi holds one
+ * connection for all of them, so what differs per chain is the network the
+ * wallet is expected to be on.
+ *
+ * On Arc the balance is the *native* USDC view (18 decimals), which is what the
+ * wallet shows. Reading USDC through its ERC-20 interface gives 6 decimals for
+ * the same balance: see `USDC_ERC20_DECIMALS` in `@intent/config`.
+ */
+function evmAdapter(
+ descriptor: ChainDescriptor,
+ chainId: number,
+ signPrepared?: ChainAdapter['signPrepared']
+): ChainAdapter {
+ function useEvmWallet(): ChainWallet {
+ const {
+ address,
+ isConnected,
+ isConnecting,
+ isReconnecting,
+ chainId: walletChain,
+ connector,
+ } = useAccount()
+ const { switchChain, isPending: isSwitching } = useSwitchChain()
+ const { connect, connectors, error: connectError } = useConnect()
+ const { disconnect } = useDisconnect()
+
+ const isWrongNetwork = isConnected && walletChain !== chainId
+
+ const { data: balanceData } = useBalance({
+ address,
+ chainId,
+ query: { enabled: isConnected && !isWrongNetwork },
+ })
+
+ return {
+ address,
+ isConnected,
+ isConnecting: isConnecting || isReconnecting,
+ isRestoring: isReconnecting,
+ isWrongNetwork,
+ balance: balanceData?.formatted,
+ balanceSymbol: balanceData?.symbol ?? descriptor.nativeCurrency.symbol,
+ // Only Arc has a faucet to send anyone to. Compared against BigInt(0)
+ // rather than a 0n literal: the TS target predates BigInt literal syntax.
+ needsFunding:
+ descriptor.slug === 'arc' &&
+ isConnected &&
+ !isWrongNetwork &&
+ balanceData !== undefined &&
+ balanceData.value === BigInt(0),
+ // An injected connector is always registered, so the browser-extension
+ // check happens at connect time rather than here.
+ isWalletUnavailable: false,
+ error: connectError?.message,
+ connect: () => {
+ const injected = connectors[0]
+ if (injected) connect({ connector: injected })
+ },
+ disconnect: () => disconnect(),
+ switchNetwork: () => switchChain({ chainId }),
+ isSwitching,
+ walletName: connector?.name,
+ walletIcon: connector?.icon,
+ }
+ }
+
+ return {
+ descriptor,
+ useWallet: useEvmWallet,
+ accountUrl: (address) => accountExplorerUrl(descriptor.slug, address),
+ ...(signPrepared !== undefined ? { signPrepared } : {}),
+ }
+}
+
+async function signAvalanche(payload: SigningPayload): Promise {
+ if (payload.family !== 'evm') {
+ return { ok: false, reason: 'wallet_error', detail: 'That is not an Avalanche transaction.' }
+ }
+ if (payload.chainId !== avalanche.id) {
+ return { ok: false, reason: 'wallet_error', detail: 'That transaction is not for Avalanche.' }
+ }
+ const { status, chainId: walletChain } = getAccount(wagmiConfig)
+ if (status !== 'connected') {
+ return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' }
+ }
+ try {
+ if (walletChain !== avalanche.id) {
+ await switchChain(wagmiConfig, { chainId: avalanche.id })
+ }
+ const hash = await sendTransaction(wagmiConfig, {
+ to: payload.to as Address,
+ data: payload.data as Hex,
+ value: BigInt(0),
+ chainId: avalanche.id,
+ })
+ return { ok: true, signed: { family: 'evm', hash } }
+ } catch (e) {
+ // viem's message goes on to list the whole request, so its short one is what is read.
+ const detail = e instanceof BaseError ? e.shortMessage : walletErrorMessage(e)
+ const declined =
+ (e instanceof BaseError &&
+ e.walk((cause) => cause instanceof UserRejectedRequestError) !== null) ||
+ isUserRejection(detail)
+ return declined
+ ? { ok: false, reason: 'rejected' }
+ : { ok: false, reason: 'wallet_error', detail }
+ }
+}
+
+export const arcAdapter = evmAdapter(arcDescriptor, arcTestnet.id)
+export const avalancheAdapter = evmAdapter(avalancheDescriptor, avalanche.id, signAvalanche)
+export const monadAdapter = evmAdapter(monadDescriptor, monad.id)
diff --git a/apps/dapp/lib/adapters/index.ts b/apps/dapp/lib/adapters/index.ts
new file mode 100644
index 00000000..8a792604
--- /dev/null
+++ b/apps/dapp/lib/adapters/index.ts
@@ -0,0 +1,36 @@
+'use client'
+
+import { nearDescriptor, solanaDescriptor, suiDescriptor } from '@intent/config'
+import type { StellarNetworkName } from '@intent/config'
+import type { ChainDescriptor, ChainSlug } from '@intent/types'
+
+import type { ChainAdapter } from '../chain-adapter'
+import { nearConnector, solanaConnector, suiConnector } from '../wallet/connectors'
+import { arcAdapter, avalancheAdapter, monadAdapter } from './evm-adapter'
+import { stellarAdapterFor } from './stellar-adapter'
+import { storeAdapter } from './store-adapter'
+
+type StoreChain = ChainDescriptor & { slug: 'solana' | 'sui' | 'near' }
+
+const solanaAdapter = storeAdapter(solanaDescriptor as StoreChain, solanaConnector)
+const suiAdapter = storeAdapter(suiDescriptor as StoreChain, suiConnector)
+const nearAdapter = storeAdapter(nearDescriptor as StoreChain, nearConnector)
+
+export function adapterFor(slug: ChainSlug, network: StellarNetworkName | undefined): ChainAdapter {
+ switch (slug) {
+ case 'stellar':
+ return stellarAdapterFor(network as StellarNetworkName)
+ case 'arc':
+ return arcAdapter
+ case 'avalanche':
+ return avalancheAdapter
+ case 'monad':
+ return monadAdapter
+ case 'solana':
+ return solanaAdapter
+ case 'sui':
+ return suiAdapter
+ case 'near':
+ return nearAdapter
+ }
+}
diff --git a/apps/dapp/lib/adapters/stellar-adapter.ts b/apps/dapp/lib/adapters/stellar-adapter.ts
index f5a76585..48f43e92 100644
--- a/apps/dapp/lib/adapters/stellar-adapter.ts
+++ b/apps/dapp/lib/adapters/stellar-adapter.ts
@@ -13,6 +13,8 @@ import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ChainAdapter, ChainWallet, SignOutcome, SignRequest } from '../chain-adapter'
+import type { SignedOutcome, SigningPayload } from '../chains/types'
+import { orderWallets, type WalletConnector } from '../wallet/connector'
import { walletErrorMessage } from '../wallet/error-message'
import { lobstrNeedsConnection } from '../wallet/lobstr'
import { Networks } from '@creit.tech/stellar-wallets-kit/types'
@@ -78,6 +80,8 @@ const listeners = new Set<() => void>()
let sharedAddress: string | undefined
let sharedNetwork: string | undefined
+/** Undecided until the first restore finishes, so a reload does not flash the connect page. */
+let sharedRestoring = true
function publish(): void {
for (const notify of listeners) notify()
@@ -95,6 +99,12 @@ function setSharedNetwork(next: string | undefined): void {
publish()
}
+function finishRestoring(): void {
+ if (!sharedRestoring) return
+ sharedRestoring = false
+ publish()
+}
+
/** Subscribes a component to connection changes. */
function useSharedConnection(): { address: string | undefined; network: string | undefined } {
const [, force] = useState(0)
@@ -144,6 +154,64 @@ function buildLoginMessage(address: string): string {
].join('\n')
}
+/**
+ * Makes an address the app's Stellar connection, and proves control of it once.
+ *
+ * This used to sign a locally-built message, discard the signature, and then
+ * have `ensureSession` prompt a *second* time for the server's challenge: two
+ * wallet popups per connect, the first proving nothing to anyone. The server's
+ * challenge is the only signature that establishes anything, so it is the only
+ * one asked for.
+ *
+ * Failure of that step is not fatal: swapping and placing orders are on-chain
+ * and need no server. Only saved history does, so a declined or unreachable
+ * sign-in still leaves a usable wallet rather than blocking the connection.
+ */
+async function establishConnection(picked: string): Promise {
+ setSharedAddress(picked)
+
+ try {
+ await ensureSession(picked)
+ // Marked verified only once a signature actually succeeded. This flag is
+ // what lets a reload restore the session without prompting again, so setting
+ // it on an unsigned connection would restore a session nobody proved.
+ writeSession({ address: picked, verified: true })
+ } catch {
+ // The wallet is connected and usable; the backend simply has no session for
+ // it. Recorded as unverified so a reload asks again rather than silently
+ // restoring an unproven address.
+ writeSession({ address: picked, verified: false })
+ }
+
+ setSharedNetwork(
+ await walletPassphrase(() => StellarWalletsKit.getNetwork(), stellarNetwork.networkPassphrase)
+ )
+}
+
+/** The connect dialog's view of Stellar: the kit's wallets, without the kit's modal. */
+export const stellarConnector: WalletConnector = {
+ isConnected: () => sharedAddress !== undefined,
+ listWallets: async () => {
+ ensureKit()
+ const wallets = await StellarWalletsKit.refreshSupportedWallets()
+ return orderWallets(
+ wallets.map((w) => ({
+ id: w.id,
+ name: w.name,
+ icon: w.icon,
+ detected: w.isAvailable,
+ installUrl: w.url,
+ }))
+ )
+ },
+ connect: async (walletId) => {
+ alignKitNetwork()
+ StellarWalletsKit.setWallet(walletId)
+ const { address } = await StellarWalletsKit.fetchAddress()
+ await establishConnection(address)
+ },
+}
+
/**
* Stellar wallet, via the Stellar Wallets Kit (Freighter, xBull, Lobstr, Rabet,
* Albedo, Hana).
@@ -205,7 +273,7 @@ function useStellarWallet(): ChainWallet {
}
}
- void restore()
+ void restore().finally(finishRestoring)
return () => {
cancelled = true
}
@@ -218,43 +286,8 @@ function useStellarWallet(): ChainWallet {
setError(undefined)
try {
- // Step 1: the user picks a wallet from the kit's modal.
const { address: picked } = await StellarWalletsKit.authModal()
-
- // Step 2: prove control of it, once.
- //
- // This used to sign a locally-built message, discard the signature, and
- // then have `ensureSession` prompt a *second* time for the server's
- // challenge — two wallet popups per connect, the first proving nothing
- // to anyone. The server's challenge is the only signature that
- // establishes anything, so it is the only one asked for.
- //
- // Failure is not fatal: swapping and placing orders are on-chain and
- // need no server. Only saved history does, so a declined or unreachable
- // sign-in still leaves a usable wallet rather than blocking the
- // connection.
- setSharedAddress(picked)
-
- try {
- await ensureSession(picked)
- // Marked verified only once a signature actually succeeded. This flag
- // is what lets a reload restore the session without prompting again,
- // so setting it on an unsigned connection would restore a session
- // nobody proved.
- writeSession({ address: picked, verified: true })
- } catch {
- // The wallet is connected and usable; the backend simply has no
- // session for it. Recorded as unverified so a reload asks again
- // rather than silently restoring an unproven address.
- writeSession({ address: picked, verified: false })
- }
-
- setSharedNetwork(
- await walletPassphrase(
- () => StellarWalletsKit.getNetwork(),
- stellarNetwork.networkPassphrase
- )
- )
+ await establishConnection(picked)
} catch (e) {
// The kit throws when the user closes the modal or rejects the
// signature — both are ordinary outcomes, not faults.
@@ -357,6 +390,7 @@ function useStellarWallet(): ChainWallet {
address,
isConnected: address !== undefined,
isConnecting,
+ isRestoring: sharedRestoring,
isWrongNetwork,
// Not shown while the wallet is on another network: the last read may be
// from the network it was on before, and a number beside a mismatch
@@ -466,6 +500,21 @@ async function signStellarTransaction(req: SignRequest): Promise {
}
}
+/** The shared signing outcome, for flows that are not tied to Stellar by shape. */
+async function signStellarPrepared(payload: SigningPayload): Promise {
+ if (payload.family !== 'stellar') {
+ return { ok: false, reason: 'wallet_error', detail: 'That is not a Stellar transaction.' }
+ }
+ const outcome = await signStellarTransaction({ xdr: payload.xdr, address: payload.account })
+ if (outcome.ok) return { ok: true, signed: { family: 'stellar', signedXdr: outcome.signedXdr } }
+ if (outcome.reason === 'rejected') return { ok: false, reason: 'rejected' }
+ return {
+ ok: false,
+ reason: 'wallet_error',
+ detail: outcome.detail ?? 'The wallet could not sign this.',
+ }
+}
+
const adapters = new Map()
/**
@@ -484,6 +533,7 @@ export function stellarAdapterFor(network: StellarNetworkName): ChainAdapter {
// Testnet only. On mainnet there is no faucet and the field is absent.
...(net.friendbotUrl !== undefined ? { faucetUrl: net.friendbotUrl } : {}),
signTransaction: signStellarTransaction,
+ signPrepared: signStellarPrepared,
}
adapters.set(network, built)
return built
diff --git a/apps/dapp/lib/adapters/store-adapter.ts b/apps/dapp/lib/adapters/store-adapter.ts
new file mode 100644
index 00000000..d332a499
--- /dev/null
+++ b/apps/dapp/lib/adapters/store-adapter.ts
@@ -0,0 +1,64 @@
+'use client'
+
+import { accountExplorerUrl } from '@intent/config'
+import type { ChainDescriptor, ChainSlug } from '@intent/types'
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useSyncExternalStore } from 'react'
+
+import type { ChainAdapter, ChainWallet } from '../chain-adapter'
+import { fetchNativeBalance } from '../wallet/native-balance'
+import type { StoreConnector } from '../wallet/store-connector'
+
+/** The adapter for a chain whose wallet lives in a `WalletStore`: Solana, Sui and NEAR. */
+export function storeAdapter(
+ descriptor: ChainDescriptor & { slug: Extract },
+ connector: StoreConnector
+): ChainAdapter {
+ const { store } = connector
+ const { slug, nativeCurrency } = descriptor
+
+ function useStoreWallet(): ChainWallet {
+ const state = useSyncExternalStore(store.subscribe, store.get, store.getServer)
+
+ useEffect(() => {
+ void connector.restore()
+ }, [])
+
+ const { address } = state
+ const { data: balance } = useQuery({
+ queryKey: ['native-balance', slug, address],
+ queryFn: () => fetchNativeBalance(slug, address as string, nativeCurrency.decimals),
+ enabled: address !== undefined,
+ refetchInterval: 15_000,
+ })
+
+ return {
+ address,
+ isConnected: address !== undefined,
+ isConnecting: false,
+ isRestoring: state.isRestoring,
+ isWrongNetwork: false,
+ balance,
+ balanceSymbol: nativeCurrency.symbol,
+ needsFunding: false,
+ isWalletUnavailable: false,
+ error: undefined,
+ walletName: state.walletName,
+ walletIcon: state.walletIcon,
+ // The connect page is the only way in, and it talks to the connector directly.
+ connect: () => undefined,
+ disconnect: () => {
+ void connector.disconnect()
+ },
+ switchNetwork: () => undefined,
+ isSwitching: false,
+ }
+ }
+
+ return {
+ descriptor,
+ useWallet: useStoreWallet,
+ signPrepared: connector.signPrepared,
+ accountUrl: (address) => accountExplorerUrl(slug, address),
+ }
+}
diff --git a/apps/dapp/lib/api/network-fetch.ts b/apps/dapp/lib/api/network-fetch.ts
index d9f972c4..c0366c8c 100644
--- a/apps/dapp/lib/api/network-fetch.ts
+++ b/apps/dapp/lib/api/network-fetch.ts
@@ -56,6 +56,17 @@ export function networkFetch(
}
}
+/**
+ * For calls that need the requester named on every deployment. `networkFetch` is
+ * only installed where several networks are served, so a single-network page would
+ * otherwise send no wallet at all.
+ */
+export function walletFetch(input: string, init?: RequestInit): Promise {
+ const headers = new Headers(init?.headers)
+ if (connectedWallet !== undefined) headers.set(WALLET_HEADER, connectedWallet)
+ return fetch(input, { ...init, headers })
+}
+
const BASE_KEY = '__intentBaseFetch'
/** Browser only. Safe to call again with another network; it wraps the original, never the wrapper. */
diff --git a/apps/dapp/lib/chain-adapter.ts b/apps/dapp/lib/chain-adapter.ts
index 52e668a5..9a06cf46 100644
--- a/apps/dapp/lib/chain-adapter.ts
+++ b/apps/dapp/lib/chain-adapter.ts
@@ -1,5 +1,7 @@
import type { ChainDescriptor, ChainSlug } from '@intent/types'
+import type { SignedOutcome, SigningPayload } from './chains/types'
+
/**
* The wallet surface every chain must provide, and the only wallet API that
* components are allowed to see.
@@ -18,6 +20,8 @@ export interface WalletSnapshot {
address: string | undefined
isConnected: boolean
isConnecting: boolean
+ /** A remembered wallet is reconnecting, so "not connected" is not yet an answer. */
+ isRestoring?: boolean
/** Connected, but the wallet is pointed at a different network. */
isWrongNetwork: boolean
/** Formatted native balance (Arc: USDC, Stellar: XLM). Undefined until read. */
@@ -92,6 +96,11 @@ export interface ChainAdapter {
* execute, so callers must check rather than assume.
*/
signTransaction?: (req: SignRequest) => Promise
+ /**
+ * Asks the wallet to sign a prepared action on a chain that is not Stellar.
+ * Separate from `signTransaction` because that one is Stellar XDR by shape.
+ */
+ signPrepared?: (payload: SigningPayload) => Promise
}
export const EMPTY_WALLET: ChainWallet = {
diff --git a/apps/dapp/lib/chain-mark.ts b/apps/dapp/lib/chain-mark.ts
new file mode 100644
index 00000000..c964faea
--- /dev/null
+++ b/apps/dapp/lib/chain-mark.ts
@@ -0,0 +1,15 @@
+const DRAWN = ['arc', 'stellar', 'solana', 'avalanche', 'sui', 'monad', 'near'] as const
+
+export type DrawnChain = (typeof DRAWN)[number]
+
+export type ChainBadge = { kind: 'mark'; chain: DrawnChain } | { kind: 'file'; slug: string }
+
+/** A chain a venue names, as the shared drawn mark or else its file under /images/chains. */
+export function chainMarkFor(chain: string): ChainBadge {
+ const slug = chain
+ .toLowerCase()
+ .replace(/\s*chain$/, '')
+ .trim()
+ const drawn = DRAWN.find((c) => c === slug)
+ return drawn !== undefined ? { kind: 'mark', chain: drawn } : { kind: 'file', slug }
+}
diff --git a/apps/dapp/lib/chain-title.ts b/apps/dapp/lib/chain-title.ts
new file mode 100644
index 00000000..e98f9229
--- /dev/null
+++ b/apps/dapp/lib/chain-title.ts
@@ -0,0 +1,4 @@
+/** A chain's name as the UI shows it: just the name, unless it is a testnet, which says so. */
+export function chainTitle(name: string, networkLabel: string): string {
+ return /testnet/i.test(networkLabel) ? networkLabel : name
+}
diff --git a/apps/dapp/lib/chains/base64.ts b/apps/dapp/lib/chains/base64.ts
new file mode 100644
index 00000000..ea466d9c
--- /dev/null
+++ b/apps/dapp/lib/chains/base64.ts
@@ -0,0 +1,13 @@
+/** Browser-safe, since wallet code has no Buffer to rely on. */
+export function bytesFromBase64(text: string): Uint8Array {
+ const binary = atob(text)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
+ return bytes
+}
+
+export function base64FromBytes(bytes: Uint8Array): string {
+ let binary = ''
+ for (const byte of bytes) binary += String.fromCharCode(byte)
+ return btoa(binary)
+}
diff --git a/apps/dapp/lib/chains/capabilities.ts b/apps/dapp/lib/chains/capabilities.ts
new file mode 100644
index 00000000..4da11184
--- /dev/null
+++ b/apps/dapp/lib/chains/capabilities.ts
@@ -0,0 +1,14 @@
+import type { ChainSlug } from '@intent/types'
+
+import type { ChainCapability } from './explain'
+import { nearCapabilities } from './near/capabilities'
+import { solanaCapabilities } from './solana/capabilities'
+import { suiCapabilities } from './sui/capabilities'
+
+/** The chain's capabilities, or nothing on a chain that doesn't use this path. */
+export function chainCapabilities(slug: ChainSlug): readonly ChainCapability[] | undefined {
+ if (slug === 'solana') return solanaCapabilities
+ if (slug === 'sui') return suiCapabilities
+ if (slug === 'near') return nearCapabilities
+ return undefined
+}
diff --git a/apps/dapp/lib/chains/explain.ts b/apps/dapp/lib/chains/explain.ts
new file mode 100644
index 00000000..a991368b
--- /dev/null
+++ b/apps/dapp/lib/chains/explain.ts
@@ -0,0 +1,22 @@
+import type { ChainActionKind } from './types'
+
+const EXAMPLE: Record string> = {
+ swap: (symbol) => `swap 10 USDC to ${symbol}`,
+ supply: (_symbol, venue) => `supply 25 USDC on ${venue}`,
+ stake: (symbol, venue) => `stake 1 ${symbol} with ${venue}`,
+}
+
+export interface ChainCapability {
+ kind: ChainActionKind
+ venue: string
+ symbol: string
+}
+
+/** What works on this chain today, said as instructions someone can copy. */
+export function chainHelp(chainName: string, capabilities: readonly ChainCapability[]): string {
+ if (capabilities.length === 0) {
+ return `I can't do that on ${chainName} yet.`
+ }
+ const examples = capabilities.map((c) => `"${EXAMPLE[c.kind](c.symbol, c.venue)}"`)
+ return `I couldn't read that as something I can do on ${chainName}. Try ${examples.join(' or ')}.`
+}
diff --git a/apps/dapp/lib/chains/near/account.ts b/apps/dapp/lib/chains/near/account.ts
new file mode 100644
index 00000000..72472a4b
--- /dev/null
+++ b/apps/dapp/lib/chains/near/account.ts
@@ -0,0 +1,6 @@
+/** A NEAR account id, as the protocol defines one. Implicit and named accounts both match. */
+export const NEAR_ACCOUNT =
+ /^(?=.{2,64}$)(?:[a-z\d]+[-_])*[a-z\d]+(?:\.(?:[a-z\d]+[-_])*[a-z\d]+)*$/
+
+/** Base58 of 32 bytes, checked before a hash from the browser goes into a URL or an API call. */
+export const NEAR_TX_HASH = /^[1-9A-HJ-NP-Za-km-z]{43,44}$/
diff --git a/apps/dapp/lib/chains/near/capabilities.ts b/apps/dapp/lib/chains/near/capabilities.ts
new file mode 100644
index 00000000..607ba4ed
--- /dev/null
+++ b/apps/dapp/lib/chains/near/capabilities.ts
@@ -0,0 +1,9 @@
+import type { ChainCapability } from '../explain'
+
+/** What actually works here, for telling someone who asked for something else. Client-safe. */
+export const nearCapabilities: ChainCapability[] = [
+ { kind: 'swap', venue: 'NEAR Intents', symbol: 'NEAR' },
+ { kind: 'stake', venue: 'Meta Pool', symbol: 'NEAR' },
+ { kind: 'stake', venue: 'rNEAR', symbol: 'NEAR' },
+ { kind: 'supply', venue: '1Click Earn', symbol: 'USDC' },
+]
diff --git a/apps/dapp/lib/chains/near/earn.ts b/apps/dapp/lib/chains/near/earn.ts
new file mode 100644
index 00000000..f32fa5a4
--- /dev/null
+++ b/apps/dapp/lib/chains/near/earn.ts
@@ -0,0 +1,74 @@
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+ type SigningPayload,
+} from '../types'
+import { assertOneClickSafe } from './safety'
+import { prepareOneClickDeposit, submitOneClick } from './swap'
+import { nearTokens, USDC_NEAR, USDT_NEAR } from './tokens'
+
+export const EARN_VENUE_ID = '1click-earn'
+const VENUE_NAME = '1Click Earn'
+
+/**
+ * Gauntlet USDC Prime, a Morpho vault on Base. Listed in the Earn docs, with this
+ * asset id and decimals confirmed against the live 1Click token list. Chosen over
+ * Taler USDC, which quotes at smaller sizes but has no independent usage data.
+ *
+ * The share stays in the user's NEAR Intents balance, because a NEAR wallet has no
+ * address on Base to deliver it to.
+ */
+const VAULT = {
+ assetId: 'nep141:base-0x0bb69b79bc829e1cfcc34a740110886d98d2bd14.omft.near',
+ symbol: 'gtUSDCp',
+ decimals: 18,
+ recipientType: 'INTENTS',
+} as const
+
+const EARN_NOTE =
+ "Your stablecoin becomes a share in Gauntlet USDC Prime, a Morpho vault on Base, held in your NEAR Intents balance. That stacks bridge, NEAR Intents and vault risk, the principal isn't guaranteed, and 1Click publishes no rate to show here. This app can't show this position or withdraw it yet. Getting out takes a separate signed withdrawal through NEAR Intents."
+
+async function prepareEarn(request: ChainActionRequest, account: string): Promise {
+ const tokenIn = tokenBySymbol(nearTokens, request.tokenIn)
+ if (request.kind !== 'supply' || (tokenIn?.id !== USDC_NEAR && tokenIn?.id !== USDT_NEAR)) {
+ throw new ChainActionRefused(`${VENUE_NAME} here takes USDC or USDT.`)
+ }
+ return prepareOneClickDeposit(
+ {
+ venueId: EARN_VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'supply',
+ summary: (amount, token) =>
+ `Put ${amount} ${token.symbol} into a vault on Base through NEAR Intents. Vault shares are small units, so the share count looks large.`,
+ extraNotes: [EARN_NOTE],
+ },
+ request.amount,
+ tokenIn,
+ VAULT,
+ account
+ )
+}
+
+/** The swap check, without the wrap path: this venue only ever spends stablecoins. */
+function assertEarnSafe(payload: SigningPayload, account: string): void {
+ assertOneClickSafe(payload, account)
+ if (payload.family !== 'near') return
+ const receiver = payload.transactions[0]?.receiverId
+ if (receiver !== USDC_NEAR && receiver !== USDT_NEAR) {
+ throw new ChainActionRefused(
+ `This transaction was stopped before signing because ${VENUE_NAME} only spends USDC or USDT. Nothing was signed or sent.`
+ )
+ }
+}
+
+export const earnVenue: ChainVenue = {
+ id: EARN_VENUE_ID,
+ name: VENUE_NAME,
+ kinds: ['supply'],
+ prepare: prepareEarn,
+ assertSafe: assertEarnSafe,
+ submit: submitOneClick,
+}
diff --git a/apps/dapp/lib/chains/near/module.ts b/apps/dapp/lib/chains/near/module.ts
new file mode 100644
index 00000000..0ad0bc51
--- /dev/null
+++ b/apps/dapp/lib/chains/near/module.ts
@@ -0,0 +1,24 @@
+import type { ChainModule } from '../types'
+import { EARN_VENUE_ID, earnVenue } from './earn'
+import { assetIdOf, oneClickPrice } from './oneclick'
+import { confirmStake, metapoolVenue, METAPOOL_VENUE_ID, rnearVenue } from './stake'
+import { ONECLICK_VENUE_ID, oneClickVenue } from './swap'
+import { nearTokens } from './tokens'
+
+export const nearModule: ChainModule = {
+ chain: 'near',
+ tokens: nearTokens,
+ venues: [oneClickVenue, metapoolVenue, rnearVenue, earnVenue],
+ defaultVenue: {
+ swap: ONECLICK_VENUE_ID,
+ supply: EARN_VENUE_ID,
+ /**
+ * Meta Pool over rNEAR because more of it could be checked: its contract source
+ * is public and was read, while the source link rNEAR publishes on-chain returns
+ * 404. Its payload is also simpler, and it doesn't lean on Rhea Lend.
+ */
+ stake: METAPOOL_VENUE_ID,
+ },
+ priceUsd: (token) => oneClickPrice(assetIdOf(token), token.decimals),
+ submit: confirmStake,
+}
diff --git a/apps/dapp/lib/chains/near/oneclick.ts b/apps/dapp/lib/chains/near/oneclick.ts
new file mode 100644
index 00000000..8e8b857c
--- /dev/null
+++ b/apps/dapp/lib/chains/near/oneclick.ts
@@ -0,0 +1,271 @@
+import { z } from 'zod'
+
+import { ChainActionRefused, type ChainToken } from '../types'
+import { NEAR_ACCOUNT } from './account'
+import { verifyQuoteSignature } from './quote-signature'
+
+const BASE_URL = 'https://1click.chaindefuser.com/v0'
+const TOKENS_TIMEOUT_MS = 10_000
+const QUOTE_TIMEOUT_MS = 15_000
+const STATUS_TIMEOUT_MS = 4_000
+const TOKENS_CACHE_MS = 30_000
+/** A price older than this is not a live price, so the trade is refused rather than valued on it. */
+const MAX_PRICE_AGE_MS = 15 * 60_000
+/** The combined fee limit is 500 bps, and 1Click adds 25 of its own without a key. */
+const MAX_APP_FEE_BPS = 475
+
+export function assetIdOf(token: ChainToken): string {
+ return `nep141:${token.id}`
+}
+
+function headers(json: boolean): Record {
+ const key = process.env['ONECLICK_API_KEY']?.trim()
+ return {
+ ...(json ? { 'Content-Type': 'application/json' } : {}),
+ ...(key !== undefined && key !== '' ? { 'X-API-Key': key } : {}),
+ }
+}
+
+/** Returns nothing unless both settings are present and sensible, so a bad setting can't overcharge. */
+export function appFees(): { recipient: string; fee: number }[] | undefined {
+ const bps = Number(process.env['ONECLICK_APP_FEE_BPS']?.trim())
+ const recipient = process.env['ONECLICK_APP_FEE_RECIPIENT']?.trim()
+ if (recipient === undefined || !NEAR_ACCOUNT.test(recipient)) return undefined
+ if (!Number.isInteger(bps) || bps < 1 || bps > MAX_APP_FEE_BPS) return undefined
+ return [{ recipient, fee: bps }]
+}
+
+const TokenList = z.array(
+ z.object({
+ assetId: z.string(),
+ decimals: z.number(),
+ symbol: z.string(),
+ price: z.number().optional(),
+ priceUpdatedAt: z.string().optional(),
+ })
+)
+export type OneClickToken = z.infer[number]
+
+let tokenCache: { at: number; tokens: OneClickToken[] } | undefined
+
+export function clearTokenCache(): void {
+ tokenCache = undefined
+}
+
+export async function listTokens(): Promise {
+ if (tokenCache !== undefined && Date.now() - tokenCache.at < TOKENS_CACHE_MS) {
+ return tokenCache.tokens
+ }
+ const response = await fetch(`${BASE_URL}/tokens`, {
+ headers: headers(false),
+ signal: AbortSignal.timeout(TOKENS_TIMEOUT_MS),
+ })
+ if (!response.ok) throw new Error(`1Click tokens responded ${response.status}`)
+ const tokens = TokenList.parse(await response.json())
+ tokenCache = { at: Date.now(), tokens }
+ return tokens
+}
+
+/**
+ * Dollars per display unit from the 1Click list, matched on the exact asset id
+ * because the list repeats symbols: it has two NEAR-chain USDTs. Stablecoins get
+ * the listed price too, since it is not exactly 1. Nothing on any doubt.
+ */
+export async function oneClickPrice(
+ assetId: string,
+ decimals: number
+): Promise {
+ try {
+ const entry = (await listTokens()).find((t) => t.assetId === assetId)
+ if (entry === undefined || entry.decimals !== decimals) return undefined
+ const { price, priceUpdatedAt } = entry
+ if (price === undefined || !Number.isFinite(price) || price <= 0) return undefined
+ const age = Date.now() - Date.parse(priceUpdatedAt ?? '')
+ if (!Number.isFinite(age) || age > MAX_PRICE_AGE_MS) return undefined
+ return price
+ } catch {
+ return undefined
+ }
+}
+
+const Digits = z.string().regex(/^\d+$/)
+
+const Quote = z.object({
+ amountIn: Digits,
+ amountInFormatted: z.string(),
+ amountInUsd: z.string(),
+ minAmountIn: Digits,
+ amountOut: Digits,
+ amountOutFormatted: z.string(),
+ amountOutUsd: z.string(),
+ minAmountOut: Digits,
+ timeEstimate: z.number().optional(),
+ refundFee: Digits.optional(),
+ withdrawFee: Digits.optional(),
+ deadline: z.string().optional(),
+ timeWhenInactive: z.string().optional(),
+ depositAddress: z.string().optional(),
+ depositMemo: z.string().optional(),
+})
+
+const QuoteRequestEcho = z
+ .object({
+ dry: z.boolean(),
+ swapType: z.string(),
+ slippageTolerance: z.number(),
+ originAsset: z.string(),
+ depositType: z.string(),
+ destinationAsset: z.string(),
+ amount: z.string(),
+ refundTo: z.string(),
+ refundType: z.string(),
+ recipient: z.string(),
+ recipientType: z.string(),
+ deadline: z.string(),
+ appFees: z.array(z.object({ fee: z.number() }).passthrough()).optional(),
+ })
+ .passthrough()
+
+const QuoteEnvelope = z.object({
+ quote: Quote.passthrough(),
+ quoteRequest: QuoteRequestEcho,
+ signature: z.string(),
+ timestamp: z.string(),
+})
+export type QuoteEnvelope = z.infer
+
+export interface QuoteInput {
+ dry: boolean
+ originAsset: string
+ destinationAsset: string
+ amount: string
+ account: string
+ recipientType: 'DESTINATION_CHAIN' | 'INTENTS'
+ deadline: string
+ slippageBps: number
+}
+
+/** A sentence for what 1Click said. Its own text is not passed on, because it is outside this app. */
+function refusalFor(status: number, message: string): ChainActionRefused {
+ if (/no liquidity/i.test(message)) {
+ return new ChainActionRefused(
+ 'NEAR Intents has no price for this right now, usually because the amount is too small or too large for the market. Try a different amount, or try again in a minute. Nothing was signed or sent.'
+ )
+ }
+ if (/minimum|too low|too small|temporary swap limit/i.test(message)) {
+ const dollars = /\$[\d,]+(?:\.\d+)?/.exec(message)?.[0]
+ return new ChainActionRefused(
+ dollars !== undefined
+ ? `NEAR Intents won't take less than ${dollars} on this route right now. Try a larger amount. Nothing was signed or sent.`
+ : 'This amount is below what NEAR Intents takes on this route right now. Try a larger amount. Nothing was signed or sent.'
+ )
+ }
+ if (status === 401 || status === 403) {
+ return new ChainActionRefused(
+ 'NEAR Intents turned this app away, so its API key needs checking. Nothing was signed or sent.'
+ )
+ }
+ if (status === 429) {
+ return new ChainActionRefused(
+ 'NEAR Intents is limiting requests right now. Try again in a minute. Nothing was signed or sent.'
+ )
+ }
+ return new ChainActionRefused(
+ 'NEAR Intents would not quote this. Try again in a minute, or try a different amount. Nothing was signed or sent.'
+ )
+}
+
+/**
+ * One quote, with its signature checked. Throws `ChainActionRefused` for anything
+ * the user should read. A `dry: false` quote creates a deposit address and moves
+ * nothing.
+ */
+export async function requestQuote(input: QuoteInput): Promise {
+ const fees = appFees()
+ const body = {
+ dry: input.dry,
+ swapType: 'EXACT_INPUT',
+ slippageTolerance: input.slippageBps,
+ originAsset: input.originAsset,
+ depositType: 'ORIGIN_CHAIN',
+ destinationAsset: input.destinationAsset,
+ amount: input.amount,
+ refundTo: input.account,
+ refundType: 'ORIGIN_CHAIN',
+ recipient: input.account,
+ recipientType: input.recipientType,
+ deadline: input.deadline,
+ ...(fees !== undefined ? { appFees: fees } : {}),
+ }
+
+ let response: Response
+ try {
+ response = await fetch(`${BASE_URL}/quote`, {
+ method: 'POST',
+ headers: headers(true),
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(QUOTE_TIMEOUT_MS),
+ })
+ } catch {
+ throw new ChainActionRefused(
+ "NEAR Intents isn't answering right now. Try again in a minute. Nothing was signed or sent."
+ )
+ }
+ const raw: unknown = await response.json().catch(() => undefined)
+ if (!response.ok) {
+ const message = (raw as { message?: unknown } | undefined)?.message
+ throw refusalFor(response.status, typeof message === 'string' ? message : '')
+ }
+ const parsed = QuoteEnvelope.safeParse(raw)
+ if (!parsed.success) {
+ throw new ChainActionRefused(
+ 'NEAR Intents sent back a quote this app cannot read, so it was not used. Nothing was signed or sent.'
+ )
+ }
+ if (!verifyQuoteSignature(parsed.data)) {
+ throw new ChainActionRefused(
+ "NEAR Intents' quote failed its signature check, so it was not used. Nothing was signed or sent."
+ )
+ }
+ return parsed.data
+}
+
+/** Optional speed-up, per the docs. A failure here changes nothing about the swap. */
+export async function notifyDeposit(
+ depositAddress: string,
+ txHash: string,
+ nearSenderAccount: string
+): Promise {
+ await fetch(`${BASE_URL}/deposit/submit`, {
+ method: 'POST',
+ headers: headers(true),
+ body: JSON.stringify({ txHash, depositAddress, nearSenderAccount }),
+ signal: AbortSignal.timeout(STATUS_TIMEOUT_MS),
+ }).catch(() => undefined)
+}
+
+const StatusBody = z.object({
+ status: z.string().optional(),
+ quoteResponse: z
+ .object({
+ quoteRequest: z.object({ recipient: z.string().optional() }).passthrough().optional(),
+ })
+ .passthrough()
+ .optional(),
+})
+export type DepositStatus = z.infer
+
+/** The swap's state, or nothing while the answer can't be read. */
+export async function depositStatus(depositAddress: string): Promise {
+ try {
+ const response = await fetch(
+ `${BASE_URL}/status?depositAddress=${encodeURIComponent(depositAddress)}`,
+ { headers: headers(false), signal: AbortSignal.timeout(STATUS_TIMEOUT_MS) }
+ )
+ if (!response.ok) return undefined
+ const parsed = StatusBody.safeParse(await response.json())
+ return parsed.success ? parsed.data : undefined
+ } catch {
+ return undefined
+ }
+}
diff --git a/apps/dapp/lib/chains/near/quote-signature.ts b/apps/dapp/lib/chains/near/quote-signature.ts
new file mode 100644
index 00000000..f193c4e6
--- /dev/null
+++ b/apps/dapp/lib/chains/near/quote-signature.ts
@@ -0,0 +1,133 @@
+import { createHash, createPublicKey, verify } from 'node:crypto'
+
+/**
+ * Checks that a 1Click quote was signed by 1Click, without its SDK. This is the
+ * same field list, hash and key as `verifyQuoteSignature` in
+ * `@defuse-protocol/one-click-sdk-typescript` 0.1.26.
+ *
+ * It catches a quote altered on the way here, including its deposit address. It
+ * cannot catch 1Click itself signing something wrong.
+ */
+const MANAGER_PUBLIC_KEY = 'reYaWhvwu8Jzo3WUM3zhn6VrhuMEF4eADL17qtRVifc'
+const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
+const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+
+function base58Encode(bytes: Buffer): string {
+ let value = BigInt(`0x${bytes.toString('hex') || '0'}`)
+ let text = ''
+ while (value > BigInt(0)) {
+ text = `${BASE58[Number(value % BigInt(58))]}${text}`
+ value /= BigInt(58)
+ }
+ for (const byte of bytes) {
+ if (byte !== 0) break
+ text = `1${text}`
+ }
+ return text
+}
+
+function base58Decode(text: string): Buffer {
+ let value = BigInt(0)
+ for (const char of text) {
+ const digit = BASE58.indexOf(char)
+ if (digit < 0) throw new Error('not base58')
+ value = value * BigInt(58) + BigInt(digit)
+ }
+ let hex = value === BigInt(0) ? '' : value.toString(16)
+ if (hex.length % 2 === 1) hex = `0${hex}`
+ let zeros = 0
+ for (const char of text) {
+ if (char !== '1') break
+ zeros += 1
+ }
+ return Buffer.concat([Buffer.alloc(zeros), Buffer.from(hex, 'hex')])
+}
+
+/** Sorted keys, undefined dropped: what `json-stable-stringify` produces for these values. */
+function stableStringify(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
+ if (typeof value === 'object' && value !== null) {
+ const record = value as Record
+ const parts = Object.keys(record)
+ .filter((key) => record[key] !== undefined)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
+ return `{${parts.join(',')}}`
+ }
+ return JSON.stringify(value)
+}
+
+export interface SignedQuoteFields {
+ quote: Record
+ quoteRequest: Record
+ signature: string
+ timestamp: string
+}
+
+function orUndefined(value: unknown): unknown {
+ return value === '' || value === 0 || value === null || value === false ? undefined : value
+}
+
+function signedMessage({ quote, quoteRequest: request, timestamp }: SignedQuoteFields): string {
+ const signedRequest = {
+ dry: request['dry'],
+ swapType: request['swapType'],
+ slippageTolerance: request['slippageTolerance'],
+ originAsset: request['originAsset'],
+ depositType: request['depositType'],
+ destinationAsset: request['destinationAsset'],
+ amount: request['amount'],
+ refundTo: request['refundTo'],
+ refundType: request['refundType'],
+ recipient: request['recipient'],
+ recipientType: request['recipientType'],
+ deadline: request['deadline'],
+ quoteWaitingTimeMs: orUndefined(request['quoteWaitingTimeMs']),
+ referral: orUndefined(request['referral']),
+ virtualChainRecipient: orUndefined(request['virtualChainRecipient']),
+ virtualChainRefundRecipient: orUndefined(request['virtualChainRefundRecipient']),
+ customRecipientMsg: orUndefined(request['customRecipientMsg']),
+ }
+ const amounts = {
+ amountIn: quote['amountIn'],
+ amountInFormatted: quote['amountInFormatted'],
+ amountInUsd: quote['amountInUsd'],
+ minAmountIn: quote['minAmountIn'],
+ amountOut: quote['amountOut'],
+ amountOutFormatted: quote['amountOutFormatted'],
+ amountOutUsd: quote['amountOutUsd'],
+ minAmountOut: quote['minAmountOut'],
+ }
+ const signedQuote =
+ request['dry'] === true
+ ? amounts
+ : {
+ ...amounts,
+ depositAddress: orUndefined(quote['depositAddress']),
+ depositMemo: orUndefined(quote['depositMemo']),
+ deadline: orUndefined(quote['deadline']),
+ timeWhenInactive: orUndefined(quote['timeWhenInactive']),
+ timeEstimate: orUndefined(quote['timeEstimate']),
+ refundFee: orUndefined(quote['refundFee']),
+ withdrawFee: orUndefined(quote['withdrawFee']),
+ }
+ const text = stableStringify({ ...signedRequest, ...signedQuote, timestamp })
+ return base58Encode(createHash('sha256').update(text).digest())
+}
+
+export function verifyQuoteSignature(
+ response: SignedQuoteFields,
+ publicKey: string = MANAGER_PUBLIC_KEY
+): boolean {
+ try {
+ const signature = base58Decode(response.signature.replace(/^ed25519:/, ''))
+ const key = createPublicKey({
+ key: Buffer.concat([ED25519_SPKI_PREFIX, base58Decode(publicKey)]),
+ format: 'der',
+ type: 'spki',
+ })
+ return verify(null, Buffer.from(signedMessage(response)), key, signature)
+ } catch {
+ return false
+ }
+}
diff --git a/apps/dapp/lib/chains/near/rpc.ts b/apps/dapp/lib/chains/near/rpc.ts
new file mode 100644
index 00000000..e713fe46
--- /dev/null
+++ b/apps/dapp/lib/chains/near/rpc.ts
@@ -0,0 +1,159 @@
+const DEFAULT_RPC_URL = 'https://free.rpc.fastnear.com'
+const VIEW_TIMEOUT_MS = 8_000
+const TX_ATTEMPT_TIMEOUT_MS = 8_000
+const TX_POLL_INTERVAL_MS = 1_500
+
+export class NearRpcError extends Error {}
+
+function rpcUrl(): string {
+ const configured = process.env['NEAR_RPC_URL']?.trim()
+ return configured === undefined || configured === '' ? DEFAULT_RPC_URL : configured
+}
+
+async function rpcCall(method: string, params: unknown, timeoutMs: number): Promise {
+ const response = await fetch(rpcUrl(), {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 'intent', method, params }),
+ signal: AbortSignal.timeout(timeoutMs),
+ })
+ if (!response.ok) throw new NearRpcError(`rpc responded ${response.status}`)
+ const body = (await response.json()) as {
+ result?: unknown
+ error?: { cause?: { name?: string }; name?: string; message?: string }
+ }
+ if (body.error !== undefined) {
+ throw new NearRpcError(body.error.cause?.name ?? body.error.name ?? 'rpc_error')
+ }
+ return body.result
+}
+
+/** A read-only contract call. Throws `NearRpcError` when the network or the contract answers badly. */
+export async function viewCall(
+ contract: string,
+ method: string,
+ args: Record = {}
+): Promise {
+ const result = (await rpcCall(
+ 'query',
+ {
+ request_type: 'call_function',
+ finality: 'final',
+ account_id: contract,
+ method_name: method,
+ args_base64: Buffer.from(JSON.stringify(args)).toString('base64'),
+ },
+ VIEW_TIMEOUT_MS
+ )) as { result?: number[]; error?: string }
+ if (!Array.isArray(result.result)) {
+ throw new NearRpcError(result.error ?? 'view call returned nothing')
+ }
+ return JSON.parse(Buffer.from(result.result).toString('utf8')) as T
+}
+
+/** Whether an account has paid for storage on a NEP-141 or NEP-145 contract. */
+export async function isRegistered(contract: string, account: string): Promise {
+ const balance = await viewCall(contract, 'storage_balance_of', { account_id: account })
+ return balance !== null
+}
+
+export interface ExecutedCall {
+ method: string
+ args: Record
+ deposit: string
+}
+
+export type TxOutcome =
+ /** `signer` is who the action ran as: the sender of a delegate action, if the wallet used one. */
+ | { state: 'success'; signer: string; receiver: string; calls: ExecutedCall[] }
+ | { state: 'failed'; signer: string; receiver: string; calls: ExecutedCall[] }
+ /** Not seen as finished before the wait ended. Says nothing about whether it will succeed. */
+ | { state: 'unknown' }
+
+interface RawAction {
+ FunctionCall?: { method_name: string; args: string; deposit: string }
+ Delegate?: { delegate_action: RawDelegate }
+}
+
+interface RawDelegate {
+ sender_id: string
+ receiver_id: string
+ actions: (RawAction | { NonDelegateAction?: RawAction })[]
+}
+
+interface RawTxResult {
+ status?: Record | string
+ transaction?: { signer_id: string; receiver_id: string; actions: (RawAction | string)[] }
+}
+
+function parseArgs(encoded: string): Record {
+ try {
+ const parsed: unknown = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'))
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}
+ } catch {
+ return {}
+ }
+}
+
+function callsOf(actions: (RawAction | string)[]): ExecutedCall[] {
+ return actions.flatMap((action) => {
+ if (typeof action === 'string' || action.FunctionCall === undefined) return []
+ const { method_name, args, deposit } = action.FunctionCall
+ return [{ method: method_name, args: parseArgs(args), deposit }]
+ })
+}
+
+function readOutcome(result: RawTxResult): TxOutcome {
+ const { status, transaction } = result
+ if (transaction === undefined || typeof status !== 'object' || status === null) {
+ return { state: 'unknown' }
+ }
+ const delegate = transaction.actions.flatMap((a) =>
+ typeof a !== 'string' && a.Delegate !== undefined ? [a.Delegate.delegate_action] : []
+ )[0]
+ const facts =
+ delegate !== undefined
+ ? {
+ signer: delegate.sender_id,
+ receiver: delegate.receiver_id,
+ calls: callsOf(
+ delegate.actions.map((a) =>
+ 'NonDelegateAction' in a && a.NonDelegateAction !== undefined
+ ? a.NonDelegateAction
+ : (a as RawAction)
+ )
+ ),
+ }
+ : {
+ signer: transaction.signer_id,
+ receiver: transaction.receiver_id,
+ calls: callsOf(transaction.actions),
+ }
+ if ('Failure' in status) return { state: 'failed', ...facts }
+ if ('SuccessValue' in status) return { state: 'success', ...facts }
+ return { state: 'unknown' }
+}
+
+/** Polls the `tx` method until the transaction finishes or the wait ends. Never throws. */
+export async function waitForTransaction(
+ hash: string,
+ senderAccount: string,
+ waitMs: number
+): Promise {
+ const giveUpAt = Date.now() + waitMs
+ while (Date.now() < giveUpAt) {
+ try {
+ const result = (await rpcCall(
+ 'tx',
+ { tx_hash: hash, sender_account_id: senderAccount, wait_until: 'EXECUTED_OPTIMISTIC' },
+ Math.min(TX_ATTEMPT_TIMEOUT_MS, Math.max(giveUpAt - Date.now(), 1))
+ )) as RawTxResult
+ const outcome = readOutcome(result)
+ if (outcome.state !== 'unknown') return outcome
+ } catch {
+ // Not indexed yet, a timeout or a brief RPC failure: all of them mean look again.
+ }
+ await new Promise((resolve) => setTimeout(resolve, TX_POLL_INTERVAL_MS))
+ }
+ return { state: 'unknown' }
+}
diff --git a/apps/dapp/lib/chains/near/safety.ts b/apps/dapp/lib/chains/near/safety.ts
new file mode 100644
index 00000000..c1ea7bcd
--- /dev/null
+++ b/apps/dapp/lib/chains/near/safety.ts
@@ -0,0 +1,240 @@
+import { ChainActionRefused, type NearTransaction, type SigningPayload } from '../types'
+import { NEAR_ACCOUNT } from './account'
+import { METAPOOL_CONTRACT, RNEAR_CONTRACT, TGAS, USDC_NEAR, USDT_NEAR, WRAP_NEAR } from './tokens'
+
+/**
+ * What a NEAR wallet may be asked to sign.
+ *
+ * A NEAR wallet sends what it signs, so the server sees only hashes afterwards.
+ * These checks are the last server-side look before signing, and each one fails
+ * closed. Anything not named here is refused, and that includes `ft_transfer_call`:
+ * nothing in this app needs it, and it would run code on the receiving account.
+ *
+ * Bounds:
+ * - `ft_transfer` attaches exactly 1 yoctoNEAR, as NEP-141 requires.
+ * - Storage attaches exactly 0.00125 NEAR, the `storage_balance_bounds` minimum
+ * on every contract here.
+ * - A stake or wrap attaches at most 1,000 NEAR. Stablecoin transfers carry at most
+ * 10,000 tokens. Dollar caps run earlier, in `prepareChainAction`. These ceilings
+ * only stop a payload far outside any trade that cap would pass.
+ * - Gas is limited per method below, and to 300 TGas for a whole transaction.
+ *
+ * What this cannot do: a 1Click deposit address comes from 1Click's API, and any
+ * 64-hex account looks the same as another. This limits what leaves and in what
+ * shape. It cannot prove the address belongs to the swap, or where funds land.
+ */
+
+export const STORAGE_DEPOSIT_YOCTO = '1250000000000000000000'
+export const MAX_NEAR_YOCTO = BigInt(1_000) * BigInt('1000000000000000000000000')
+export const MAX_STABLE_UNITS = BigInt(10_000) * BigInt(1000000)
+export const MAX_TX_GAS = BigInt(300) * TGAS
+export const STORAGE_GAS = BigInt(30) * TGAS
+export const WRAP_GAS = BigInt(30) * TGAS
+export const TRANSFER_GAS = BigInt(30) * TGAS
+/** From `rnear-sdk`, which uses 200 TGas. */
+export const RNEAR_STAKE_GAS = BigInt(200) * TGAS
+/** Meta Pool's SDK uses 50 TGas. Doubled for margin, and unused gas comes back. */
+export const METAPOOL_STAKE_GAS = BigInt(100) * TGAS
+
+/** Every 1Click deposit address seen on NEAR is a 64-hex implicit account. */
+export const DEPOSIT_ADDRESS = /^[0-9a-f]{64}$/
+
+const SWAP_CONTRACTS = new Set([WRAP_NEAR, USDC_NEAR, USDT_NEAR])
+
+function refuse(reason: string): never {
+ throw new ChainActionRefused(
+ `This transaction was stopped before signing because ${reason}. Nothing was signed or sent.`
+ )
+}
+
+function hasExactKeys(value: unknown, keys: readonly string[]): boolean {
+ if (typeof value !== 'object' || value === null) return false
+ const actual = Object.keys(value)
+ return actual.length === keys.length && keys.every((key) => actual.includes(key))
+}
+
+function isDigits(value: unknown): value is string {
+ return typeof value === 'string' && /^\d+$/.test(value)
+}
+
+interface Call {
+ method: string
+ args: Record
+ gas: bigint
+ deposit: bigint
+}
+
+/** The one transaction a venue may send, read strictly. Unknown shapes are refused. */
+function readSingleTransaction(
+ payload: SigningPayload,
+ account: string
+): NearTransaction & {
+ calls: Call[]
+} {
+ if (payload.family !== 'near') refuse('it is not a NEAR transaction')
+ if (!NEAR_ACCOUNT.test(account)) refuse('the account is not a NEAR account')
+ const { transactions } = payload
+ if (!Array.isArray(transactions) || transactions.length !== 1) {
+ refuse('it is not exactly one transaction')
+ }
+ const transaction = transactions[0] as NearTransaction
+ if (!hasExactKeys(transaction, ['receiverId', 'actions'])) {
+ refuse('the transaction carries unexpected fields')
+ }
+ if (!Array.isArray(transaction.actions) || transaction.actions.length === 0) {
+ refuse('the transaction has no actions')
+ }
+
+ let totalGas = BigInt(0)
+ const calls = transaction.actions.map((action): Call => {
+ if (action.type !== 'FunctionCall' || !hasExactKeys(action, ['type', 'params'])) {
+ refuse('it contains something other than a contract call')
+ }
+ const { params } = action
+ if (!hasExactKeys(params, ['methodName', 'args', 'gas', 'deposit'])) {
+ refuse('a contract call carries unexpected fields')
+ }
+ if (typeof params.args !== 'object' || params.args === null || Array.isArray(params.args)) {
+ refuse('a contract call has malformed arguments')
+ }
+ if (!isDigits(params.gas) || !isDigits(params.deposit)) {
+ refuse('a contract call has a malformed gas or deposit amount')
+ }
+ const gas = BigInt(params.gas)
+ if (gas === BigInt(0)) refuse('a contract call has no gas')
+ totalGas += gas
+ return {
+ method: params.methodName,
+ args: params.args,
+ gas,
+ deposit: BigInt(params.deposit),
+ }
+ })
+ if (totalGas > MAX_TX_GAS) refuse('it asks for more gas than this app allows')
+ return { ...transaction, calls }
+}
+
+function assertGas(call: Call, limit: bigint): void {
+ if (call.gas > limit) refuse(`${call.method} asks for more gas than this app allows`)
+}
+
+function assertStorageDeposit(call: Call, allowedAccounts: readonly string[]): void {
+ assertGas(call, STORAGE_GAS)
+ if (call.deposit !== BigInt(STORAGE_DEPOSIT_YOCTO)) {
+ refuse('a storage payment is not the expected 0.00125 NEAR')
+ }
+ if (
+ !hasExactKeys(call.args, ['account_id', 'registration_only']) ||
+ call.args['registration_only'] !== true
+ ) {
+ refuse('a storage payment has unexpected arguments')
+ }
+ const target = call.args['account_id']
+ if (typeof target !== 'string' || !allowedAccounts.includes(target)) {
+ refuse('a storage payment is for an unexpected account')
+ }
+}
+
+/**
+ * A 1Click deposit: one transaction to one token contract, ending in the single
+ * `ft_transfer` that pays the deposit address.
+ *
+ * USDC or USDT: [storage_deposit(deposit address)?, ft_transfer]
+ * NEAR on wrap.near: [storage_deposit(you)?, near_deposit, storage_deposit(deposit address)?, ft_transfer]
+ *
+ * Actions in one transaction run as one receipt, so if the transfer fails the wrap
+ * and any storage payment are reverted and refunded too.
+ */
+export function assertOneClickSafe(payload: SigningPayload, account: string): void {
+ const transaction = readSingleTransaction(payload, account)
+ if (!SWAP_CONTRACTS.has(transaction.receiverId)) {
+ refuse(`${transaction.receiverId} is not a token this app trades`)
+ }
+ const { calls } = transaction
+ const transfer = calls[calls.length - 1] as Call
+ if (transfer.method !== 'ft_transfer') refuse('it does not end in a token transfer')
+
+ assertGas(transfer, TRANSFER_GAS)
+ if (transfer.deposit !== BigInt(1))
+ refuse('the token transfer does not attach exactly 1 yoctoNEAR')
+ if (!hasExactKeys(transfer.args, ['receiver_id', 'amount'])) {
+ refuse('the token transfer has unexpected arguments')
+ }
+ const depositAddress = transfer.args['receiver_id']
+ const amountText = transfer.args['amount']
+ if (typeof depositAddress !== 'string' || !DEPOSIT_ADDRESS.test(depositAddress)) {
+ refuse('the token transfer does not go to a deposit address')
+ }
+ if (depositAddress === account) refuse('the token transfer pays your own account')
+ if (!isDigits(amountText)) refuse('the token transfer has a malformed amount')
+ const amount = BigInt(amountText)
+ if (amount === BigInt(0)) refuse('the token transfer moves nothing')
+ const isWrapped = transaction.receiverId === WRAP_NEAR
+ if (amount > (isWrapped ? MAX_NEAR_YOCTO : MAX_STABLE_UNITS)) {
+ refuse('the token transfer is larger than this app allows')
+ }
+
+ const before = calls.slice(0, -1)
+ const wrapIndex = before.findIndex((c) => c.method === 'near_deposit')
+ before.forEach((call, index) => {
+ if (call.method === 'storage_deposit') {
+ // Your own storage is only ever paid for just before wrapping.
+ const selfAllowed = isWrapped && wrapIndex > index
+ assertStorageDeposit(call, selfAllowed ? [account] : [depositAddress])
+ return
+ }
+ if (call.method === 'near_deposit') {
+ if (!isWrapped) refuse('near_deposit is only allowed on wrap.near')
+ if (index !== wrapIndex) refuse('it wraps NEAR more than once')
+ assertGas(call, WRAP_GAS)
+ if (Object.keys(call.args).length !== 0) refuse('near_deposit has unexpected arguments')
+ if (call.deposit !== amount) refuse('the amount wrapped is not the amount sent')
+ return
+ }
+ refuse(`${call.method} is not allowed`)
+ })
+ if (isWrapped && wrapIndex === -1) refuse('it sends wNEAR without wrapping it first')
+
+ const paidFor = before
+ .filter((c) => c.method === 'storage_deposit')
+ .map((c) => c.args['account_id'])
+ if (new Set(paidFor).size !== paidFor.length) refuse('it pays for the same storage twice')
+}
+
+function assertStake(
+ payload: SigningPayload,
+ account: string,
+ contract: string,
+ stakeGas: bigint,
+ allowStorage: boolean
+): void {
+ const transaction = readSingleTransaction(payload, account)
+ if (transaction.receiverId !== contract) refuse(`${transaction.receiverId} is not this venue`)
+ const { calls } = transaction
+ const stake = calls[calls.length - 1] as Call
+ if (stake.method !== 'deposit_and_stake') refuse('it does not end in a stake')
+ assertGas(stake, stakeGas)
+ if (Object.keys(stake.args).length !== 0) refuse('deposit_and_stake has unexpected arguments')
+ if (stake.deposit === BigInt(0)) refuse('the stake attaches nothing')
+ if (stake.deposit > MAX_NEAR_YOCTO) refuse('the stake is larger than this app allows')
+
+ const before = calls.slice(0, -1)
+ if (before.length > (allowStorage ? 1 : 0)) refuse('it contains more than a stake')
+ for (const call of before) {
+ if (call.method !== 'storage_deposit') refuse(`${call.method} is not allowed`)
+ assertStorageDeposit(call, [account])
+ }
+}
+
+/** [storage_deposit(you)?, deposit_and_stake] on `lst.rhealab.near`. */
+export function assertRnearSafe(payload: SigningPayload, account: string): void {
+ assertStake(payload, account, RNEAR_CONTRACT, RNEAR_STAKE_GAS, true)
+}
+
+/**
+ * [deposit_and_stake] on `meta-pool.near`. Its NEP-145 methods are empty stubs and
+ * a new account's storage comes out of the first stake, so no storage call is allowed.
+ */
+export function assertMetapoolSafe(payload: SigningPayload, account: string): void {
+ assertStake(payload, account, METAPOOL_CONTRACT, METAPOOL_STAKE_GAS, false)
+}
diff --git a/apps/dapp/lib/chains/near/stake.ts b/apps/dapp/lib/chains/near/stake.ts
new file mode 100644
index 00000000..009cd178
--- /dev/null
+++ b/apps/dapp/lib/chains/near/stake.ts
@@ -0,0 +1,252 @@
+import {
+ ChainActionRefused,
+ toBaseUnits,
+ tokenBySymbol,
+ type ChainActionRequest,
+ type ChainVenue,
+ type NearTransaction,
+ type PreparedAction,
+ type SubmitInput,
+ type SubmitResult,
+} from '../types'
+import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account'
+import { isRegistered, viewCall, waitForTransaction } from './rpc'
+import {
+ assertMetapoolSafe,
+ assertRnearSafe,
+ METAPOOL_STAKE_GAS,
+ RNEAR_STAKE_GAS,
+ STORAGE_DEPOSIT_YOCTO,
+ STORAGE_GAS,
+} from './safety'
+import {
+ explorerUrl,
+ formatUnits,
+ METAPOOL_CONTRACT,
+ nearTokens,
+ RNEAR_CONTRACT,
+ WRAP_NEAR,
+ YOCTO_PER_NEAR,
+} from './tokens'
+
+export const RNEAR_VENUE_ID = 'rnear'
+export const METAPOOL_VENUE_ID = 'metapool'
+
+const RHEA_APY_URL = 'https://api.rhea.finance/get-rnear-apy'
+const APY_TIMEOUT_MS = 5_000
+const CONFIRM_WAIT_MS = 25_000
+
+function cannotRead(venue: string): ChainActionRefused {
+ return new ChainActionRefused(
+ `${venue} could not be read from NEAR just now, so nothing was built. Try again in a minute.`
+ )
+}
+
+/** Yocto of NEAR staked, read as an amount of the staking token at the contract's price. */
+function receiptAmount(stakedYocto: string, pricePerToken: string): string {
+ const price = BigInt(pricePerToken)
+ if (price <= BigInt(0)) throw new Error('no price')
+ return formatUnits(((BigInt(stakedYocto) * YOCTO_PER_NEAR) / price).toString(), 24)
+}
+
+/** Only ever a rate from Rhea's own endpoint. Without one, the card simply shows no rate. */
+async function rnearApyPercent(): Promise {
+ try {
+ const response = await fetch(RHEA_APY_URL, { signal: AbortSignal.timeout(APY_TIMEOUT_MS) })
+ if (!response.ok) return undefined
+ const { data } = (await response.json()) as { data?: unknown }
+ const percent = Number(data)
+ return typeof data === 'string' && Number.isFinite(percent) && percent > 0 && percent < 100
+ ? percent
+ : undefined
+ } catch {
+ return undefined
+ }
+}
+
+function assertStakeRequest(request: ChainActionRequest, account: string, venue: string): void {
+ if (!NEAR_ACCOUNT.test(account)) {
+ throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.')
+ }
+ const token = tokenBySymbol(nearTokens, request.tokenIn)
+ if (request.kind !== 'stake' || token?.id !== WRAP_NEAR) {
+ throw new ChainActionRefused(`${venue} only stakes NEAR. Swap to NEAR first, then stake it.`)
+ }
+}
+
+function stakeCall(amount: string, gas: bigint): NearTransaction['actions'][number] {
+ return {
+ type: 'FunctionCall',
+ params: { methodName: 'deposit_and_stake', args: {}, gas: gas.toString(), deposit: amount },
+ }
+}
+
+async function prepareRnear(request: ChainActionRequest, account: string): Promise {
+ const venue = 'rNEAR'
+ assertStakeRequest(request, account, venue)
+ const amount = toBaseUnits(request.amount, 24)
+
+ let price: string
+ let registered: boolean
+ let paused: boolean
+ try {
+ ;[price, registered, paused] = await Promise.all([
+ viewCall(RNEAR_CONTRACT, 'ft_price'),
+ isRegistered(RNEAR_CONTRACT, account),
+ viewCall(RNEAR_CONTRACT, 'is_paused'),
+ ])
+ receiptAmount(amount, price)
+ } catch {
+ throw cannotRead(venue)
+ }
+ if (paused) {
+ throw new ChainActionRefused('rNEAR staking is paused right now. Nothing was signed or sent.')
+ }
+ const apy = await rnearApyPercent()
+
+ const actions: NearTransaction['actions'] = []
+ if (!registered) {
+ actions.push({
+ type: 'FunctionCall',
+ params: {
+ methodName: 'storage_deposit',
+ args: { account_id: account, registration_only: true },
+ gas: STORAGE_GAS.toString(),
+ deposit: STORAGE_DEPOSIT_YOCTO,
+ },
+ })
+ }
+ actions.push(stakeCall(amount, RNEAR_STAKE_GAS))
+
+ return {
+ venueId: RNEAR_VENUE_ID,
+ venueName: venue,
+ kind: 'stake',
+ summary: `Stake ${request.amount} NEAR with Rhea and receive rNEAR.`,
+ pay: { amount: request.amount, symbol: 'NEAR' },
+ receive: { amount: receiptAmount(amount, price), symbol: 'rNEAR', approximate: true },
+ ...(apy !== undefined ? { apy: { basePercent: apy } } : {}),
+ notes: [
+ "rNEAR's price rises only as staking rewards come in, and the rate changes, so no return is guaranteed. Leaving takes about 30 hours at full price, or happens right away through a swap at the market price.",
+ 'Most rNEAR sits in Rhea Lend, which lost about $18.4M to an exploit in April 2026. Trouble there could hurt the price of rNEAR and the instant way out.',
+ ...(registered
+ ? []
+ : ['This first stake also pays 0.00125 NEAR to register you on the rNEAR token.']),
+ ],
+ signing: { family: 'near', transactions: [{ receiverId: RNEAR_CONTRACT, actions }] },
+ }
+}
+
+async function prepareMetapool(
+ request: ChainActionRequest,
+ account: string
+): Promise {
+ const venue = 'Meta Pool'
+ assertStakeRequest(request, account, venue)
+ const amount = toBaseUnits(request.amount, 24)
+
+ let price: string
+ let minimum: string
+ try {
+ const [stPrice, params] = await Promise.all([
+ viewCall(METAPOOL_CONTRACT, 'get_st_near_price'),
+ viewCall<{ min_deposit_amount: string }>(METAPOOL_CONTRACT, 'get_contract_params'),
+ ])
+ price = stPrice
+ minimum = params.min_deposit_amount
+ receiptAmount(amount, price)
+ BigInt(minimum)
+ } catch {
+ throw cannotRead(venue)
+ }
+ if (BigInt(amount) < BigInt(minimum)) {
+ throw new ChainActionRefused(
+ `Meta Pool takes at least ${formatUnits(minimum, 24)} NEAR. Try a larger amount. Nothing was signed or sent.`
+ )
+ }
+
+ return {
+ venueId: METAPOOL_VENUE_ID,
+ venueName: venue,
+ kind: 'stake',
+ summary: `Stake ${request.amount} NEAR with Meta Pool and receive stNEAR.`,
+ pay: { amount: request.amount, symbol: 'NEAR' },
+ receive: { amount: receiptAmount(amount, price), symbol: 'stNEAR', approximate: true },
+ notes: [
+ "stNEAR's price rises only as staking rewards come in, and the rate changes, so no return is guaranteed.",
+ 'Leaving takes about 4 epochs, around 30 hours at the current pace, or happens right away through its liquidity pool at a discount of 0.3% to 5%.',
+ 'Your NEAR is held by a smart contract that delegates to many validators, so a contract bug or validator failure could cost you.',
+ ],
+ signing: {
+ family: 'near',
+ transactions: [
+ { receiverId: METAPOOL_CONTRACT, actions: [stakeCall(amount, METAPOOL_STAKE_GAS)] },
+ ],
+ },
+ }
+}
+
+export const rnearVenue: ChainVenue = {
+ id: RNEAR_VENUE_ID,
+ name: 'rNEAR',
+ kinds: ['stake'],
+ prepare: prepareRnear,
+ assertSafe: assertRnearSafe,
+}
+
+export const metapoolVenue: ChainVenue = {
+ id: METAPOOL_VENUE_ID,
+ name: 'Meta Pool',
+ kinds: ['stake'],
+ prepare: prepareMetapool,
+ assertSafe: assertMetapoolSafe,
+}
+
+const STAKE_CONTRACTS: Record = {
+ [RNEAR_VENUE_ID]: RNEAR_CONTRACT,
+ [METAPOOL_VENUE_ID]: METAPOOL_CONTRACT,
+}
+
+/**
+ * Confirms what a NEAR wallet already sent. Used for the staking venues. Each
+ * transaction must have run as this account against this venue's contract, so a
+ * hash from somewhere else can't be reported as a stake.
+ */
+export async function confirmStake(input: SubmitInput): Promise {
+ const { account, venueId, signed } = input
+ if (signed.family !== 'near') return { ok: false, error: 'That is not a NEAR transaction.' }
+ const contract = Object.prototype.hasOwnProperty.call(STAKE_CONTRACTS, venueId)
+ ? STAKE_CONTRACTS[venueId]
+ : undefined
+ if (contract === undefined) return { ok: false, error: 'Nothing to confirm for that venue.' }
+
+ const hash = signed.hashes[signed.hashes.length - 1]
+ if (hash === undefined || signed.hashes.some((h) => !NEAR_TX_HASH.test(h))) {
+ return { ok: false, error: 'The wallet did not return a transaction hash to follow.' }
+ }
+
+ const giveUpAt = Date.now() + CONFIRM_WAIT_MS
+ for (const each of signed.hashes) {
+ const outcome = await waitForTransaction(each, account, Math.max(giveUpAt - Date.now(), 1))
+ if (outcome.state === 'unknown') {
+ return {
+ ok: false,
+ error: `Your transaction was sent (${each}) but hasn't been confirmed yet. Don't send it again. Check ${explorerUrl(each)} in a minute.`,
+ }
+ }
+ if (outcome.state === 'failed') {
+ return {
+ ok: false,
+ error: `The stake failed on NEAR (${each}), so your NEAR came back to your account, apart from the gas. You can try again.`,
+ }
+ }
+ const stakes = outcome.calls.some((c) => c.method === 'deposit_and_stake')
+ if (outcome.signer !== account || outcome.receiver !== contract || !stakes) {
+ return {
+ ok: false,
+ error: `Transaction ${each} is not this account's stake with this venue. Check your wallet's activity.`,
+ }
+ }
+ }
+ return { ok: true, hash, explorerUrl: explorerUrl(hash) }
+}
diff --git a/apps/dapp/lib/chains/near/swap.ts b/apps/dapp/lib/chains/near/swap.ts
new file mode 100644
index 00000000..7fc45a9f
--- /dev/null
+++ b/apps/dapp/lib/chains/near/swap.ts
@@ -0,0 +1,433 @@
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainToken,
+ type ChainVenue,
+ type NearTransaction,
+ type PreparedAction,
+ type SubmitInput,
+ type SubmitResult,
+} from '../types'
+import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account'
+import {
+ assetIdOf,
+ depositStatus,
+ notifyDeposit,
+ oneClickPrice,
+ requestQuote,
+ type QuoteEnvelope,
+} from './oneclick'
+import { isRegistered, waitForTransaction } from './rpc'
+import {
+ assertOneClickSafe,
+ DEPOSIT_ADDRESS,
+ STORAGE_DEPOSIT_YOCTO,
+ STORAGE_GAS,
+ TRANSFER_GAS,
+ WRAP_GAS,
+} from './safety'
+import { explorerUrl, formatUnits, nearTokens, toDisplayNumber, WRAP_NEAR } from './tokens'
+
+export const ONECLICK_VENUE_ID = '1click'
+const SLIPPAGE_BPS = 100
+const QUOTE_WINDOW_MS = 5 * 60_000
+/** A quote worth this much less than it costs is refused. */
+const MAX_VALUE_LOSS = 0.05
+
+/**
+ * The status wait counts from the start and includes the confirm wait. With one
+ * last status request and a pause on top, the worst case is still under 30 seconds.
+ */
+const CONFIRM_WAIT_MS = 10_000
+const STATUS_WAIT_MS = 23_000
+const STATUS_POLL_MS = 2_000
+
+const EXPLORER_NOTE = 'You can follow it at explorer.near-intents.org by searching for'
+
+interface Destination {
+ assetId: string
+ symbol: string
+ decimals: number
+ recipientType: 'DESTINATION_CHAIN' | 'INTENTS'
+}
+
+function lossBetween(paid: number, received: number): number {
+ return paid > 0 && Number.isFinite(paid) && Number.isFinite(received)
+ ? 1 - received / paid
+ : Number.NaN
+}
+
+/**
+ * Refuses a quote that gives back too little. It is measured twice, from the
+ * quote's own dollar figures and from the token list's prices, and the worse one
+ * counts. Both come from 1Click, so this catches bad routes and thin markets. It
+ * would not catch 1Click lying about both at once.
+ */
+async function assertFairValue(
+ envelope: QuoteEnvelope,
+ tokenIn: ChainToken,
+ destination: Destination
+): Promise {
+ const { quote } = envelope
+ const [priceIn, priceOut] = await Promise.all([
+ oneClickPrice(assetIdOf(tokenIn), tokenIn.decimals),
+ oneClickPrice(destination.assetId, destination.decimals),
+ ])
+ const quoted = lossBetween(Number(quote.amountInUsd), Number(quote.amountOutUsd))
+ const listed =
+ priceIn === undefined || priceOut === undefined
+ ? Number.NaN
+ : lossBetween(
+ toDisplayNumber(quote.amountIn, tokenIn.decimals) * priceIn,
+ toDisplayNumber(quote.amountOut, destination.decimals) * priceOut
+ )
+ if (!Number.isFinite(quoted) || !Number.isFinite(listed)) {
+ throw new ChainActionRefused(
+ "This quote couldn't be checked against live prices, so it was not used. Try again in a minute. Nothing was signed or sent."
+ )
+ }
+ const worst = Math.max(quoted, listed)
+ if (worst > MAX_VALUE_LOSS) {
+ throw new ChainActionRefused(
+ `You would get about ${(worst * 100).toFixed(1)}% less than you pay, which is more than this app allows. Try a smaller amount. Nothing was signed or sent.`
+ )
+ }
+ return worst
+}
+
+/** Refuses a quote that doesn't say what was asked, whether or not it is signed. */
+function assertQuoteMatches(
+ envelope: QuoteEnvelope,
+ input: {
+ dry: boolean
+ originAsset: string
+ destinationAsset: string
+ amount: string
+ account: string
+ recipientType: Destination['recipientType']
+ deadline: string
+ }
+): void {
+ const { quote, quoteRequest: asked } = envelope
+ const matches =
+ asked.dry === input.dry &&
+ asked.swapType === 'EXACT_INPUT' &&
+ asked.depositType === 'ORIGIN_CHAIN' &&
+ asked.originAsset === input.originAsset &&
+ asked.destinationAsset === input.destinationAsset &&
+ asked.amount === input.amount &&
+ asked.recipient === input.account &&
+ asked.recipientType === input.recipientType &&
+ asked.refundTo === input.account &&
+ asked.refundType === 'ORIGIN_CHAIN' &&
+ asked.deadline === input.deadline &&
+ asked.slippageTolerance <= SLIPPAGE_BPS &&
+ quote.amountIn === input.amount &&
+ quote.minAmountIn === input.amount &&
+ BigInt(quote.amountOut) > BigInt(0) &&
+ BigInt(quote.minAmountOut) <= BigInt(quote.amountOut) &&
+ BigInt(quote.minAmountOut) * BigInt(10_000) >=
+ BigInt(quote.amountOut) * BigInt(10_000 - SLIPPAGE_BPS) - BigInt(10_000)
+ if (!matches) {
+ throw new ChainActionRefused(
+ "NEAR Intents' quote didn't match what was asked, so it was not used. Nothing was signed or sent."
+ )
+ }
+}
+
+function functionCall(
+ methodName: string,
+ args: Record,
+ gas: bigint,
+ deposit: string
+): NearTransaction['actions'][number] {
+ return {
+ type: 'FunctionCall',
+ params: { methodName, args, gas: gas.toString(), deposit },
+ }
+}
+
+function storageFor(account: string): NearTransaction['actions'][number] {
+ return functionCall(
+ 'storage_deposit',
+ { account_id: account, registration_only: true },
+ STORAGE_GAS,
+ STORAGE_DEPOSIT_YOCTO
+ )
+}
+
+/**
+ * What the wallet sends. One transaction, so its actions succeed or revert
+ * together. A NEP-141 transfer needs the deposit address registered on the token,
+ * and fresh deposit addresses are not. `wrap.near` takes the first registration out
+ * of a wrap, which would leave too little to transfer, so an unregistered sender
+ * pays for storage first.
+ */
+export async function buildDepositTransaction(
+ tokenIn: ChainToken,
+ account: string,
+ depositAddress: string,
+ amount: string
+): Promise {
+ const actions: NearTransaction['actions'] = []
+ try {
+ if (tokenIn.id === WRAP_NEAR) {
+ if (!(await isRegistered(WRAP_NEAR, account))) actions.push(storageFor(account))
+ actions.push(functionCall('near_deposit', {}, WRAP_GAS, amount))
+ }
+ if (!(await isRegistered(tokenIn.id, depositAddress))) actions.push(storageFor(depositAddress))
+ } catch {
+ throw new ChainActionRefused(
+ "NEAR's network could not be read just now, so nothing was built. Try again in a minute."
+ )
+ }
+ actions.push(
+ functionCall('ft_transfer', { receiver_id: depositAddress, amount }, TRANSFER_GAS, '1')
+ )
+ return { receiverId: tokenIn.id, actions }
+}
+
+function feeBps(envelope: QuoteEnvelope): number | undefined {
+ const fees = envelope.quoteRequest.appFees
+ return fees === undefined ? undefined : fees.reduce((sum, fee) => sum + fee.fee, 0)
+}
+
+function isPositive(amount: string | undefined): amount is string {
+ return amount !== undefined && BigInt(amount) > BigInt(0)
+}
+
+function costNote(envelope: QuoteEnvelope, loss: number, symbol: string, decimals: number): string {
+ const bps = feeBps(envelope)
+ const fee =
+ bps === undefined
+ ? "The quote includes 1Click's fee."
+ : `The quote includes a ${(bps / 100).toFixed(2)}% fee, taken from what you send.`
+ const withdraw = envelope.quote.withdrawFee
+ const registration = isPositive(withdraw)
+ ? ` It also takes ${formatUnits(withdraw, decimals)} ${symbol} to register you on that token.`
+ : ''
+ const percent = (Math.max(loss, 0) * 100).toFixed(2)
+ return `NEAR Intents runs on mainnet only, so this moves real funds. ${fee}${registration} After all costs you get about ${percent}% less in value than you pay, and at least ${formatUnits(envelope.quote.minAmountOut, decimals)} ${symbol}.`
+}
+
+interface DepositFlow {
+ venueId: string
+ venueName: string
+ kind: PreparedAction['kind']
+ summary: (amount: string, tokenIn: ChainToken) => string
+ extraNotes: string[]
+}
+
+/** The quote flow behind every 1Click venue. Only the destination and the wording differ. */
+export async function prepareOneClickDeposit(
+ flow: DepositFlow,
+ amountText: string,
+ tokenIn: ChainToken,
+ destination: Destination,
+ account: string
+): Promise {
+ if (!NEAR_ACCOUNT.test(account)) {
+ throw new ChainActionRefused('That is not a NEAR account. Connect a NEAR wallet and try again.')
+ }
+ const amount = toBaseUnits(amountText, tokenIn.decimals)
+ const shared = {
+ originAsset: assetIdOf(tokenIn),
+ destinationAsset: destination.assetId,
+ amount,
+ account,
+ recipientType: destination.recipientType,
+ slippageBps: SLIPPAGE_BPS,
+ }
+
+ const dryDeadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString()
+ const preview = await requestQuote({ ...shared, dry: true, deadline: dryDeadline })
+ assertQuoteMatches(preview, { ...shared, dry: true, deadline: dryDeadline })
+ await assertFairValue(preview, tokenIn, destination)
+
+ const deadline = new Date(Date.now() + QUOTE_WINDOW_MS).toISOString()
+ const live = await requestQuote({ ...shared, dry: false, deadline })
+ assertQuoteMatches(live, { ...shared, dry: false, deadline })
+ const loss = await assertFairValue(live, tokenIn, destination)
+
+ const { depositAddress, depositMemo } = live.quote
+ if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) {
+ throw new ChainActionRefused(
+ "NEAR Intents' deposit address didn't look right, so it was not used. Nothing was signed or sent."
+ )
+ }
+ if (depositMemo !== undefined && depositMemo !== '') {
+ throw new ChainActionRefused(
+ 'NEAR Intents asked for a deposit memo, which this app does not send. Nothing was signed or sent.'
+ )
+ }
+
+ const transaction = await buildDepositTransaction(tokenIn, account, depositAddress, amount)
+ const refundFee = live.quote.refundFee
+ const refundNote = isPositive(refundFee)
+ ? `, minus a fee of ${formatUnits(refundFee, tokenIn.decimals)} ${tokenIn.symbol}`
+ : ''
+
+ return {
+ venueId: flow.venueId,
+ venueName: flow.venueName,
+ kind: flow.kind,
+ summary: flow.summary(amountText, tokenIn),
+ pay: { amount: amountText, symbol: tokenIn.symbol },
+ receive: {
+ amount: formatUnits(live.quote.amountOut, destination.decimals),
+ symbol: destination.symbol,
+ approximate: true,
+ },
+ notes: [
+ costNote(live, loss, destination.symbol, destination.decimals),
+ `Your funds go to a one-time deposit address from 1Click, which this app can't check on-chain. Sign within 5 minutes, because a late deposit may be refunded instead of swapped. If the swap can't finish, 1Click returns your funds to ${account}${refundNote}.`,
+ ...flow.extraNotes,
+ ],
+ signing: { family: 'near', transactions: [transaction] },
+ context: { depositAddress, deadline },
+ }
+}
+
+async function prepareSwap(request: ChainActionRequest, account: string): Promise {
+ const tokenIn = tokenBySymbol(nearTokens, request.tokenIn)
+ const tokenOut = tokenBySymbol(nearTokens, request.tokenOut ?? '')
+ if (request.kind !== 'swap' || tokenIn === undefined || tokenOut === undefined) {
+ throw new ChainActionRefused('NEAR Intents here swaps between NEAR, USDC and USDT.')
+ }
+ if (tokenIn.id === tokenOut.id) throw new ChainActionRefused('Those are the same token.')
+ const toNear = tokenOut.id === WRAP_NEAR
+ return prepareOneClickDeposit(
+ {
+ venueId: ONECLICK_VENUE_ID,
+ venueName: 'NEAR Intents 1Click',
+ kind: 'swap',
+ summary: (amount, token) =>
+ token.id === WRAP_NEAR
+ ? `Wrap ${amount} NEAR and swap it for ${tokenOut.symbol} through NEAR Intents, into your NEAR account.`
+ : toNear
+ ? `Swap ${amount} ${token.symbol} for NEAR through NEAR Intents, delivered to your NEAR account as NEAR or wNEAR.`
+ : `Swap ${amount} ${token.symbol} for ${tokenOut.symbol} through NEAR Intents, into your NEAR account.`,
+ extraNotes: [
+ 'NEAR Intents had an exploit of about $3.8M around 1 October 2026 in its bridge links. Funds were reported returned, but bridges and solvers it relies on remain a risk.',
+ ],
+ },
+ request.amount,
+ tokenIn,
+ {
+ assetId: assetIdOf(tokenOut),
+ symbol: tokenOut.symbol,
+ decimals: tokenOut.decimals,
+ recipientType: 'DESTINATION_CHAIN',
+ },
+ account
+ )
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function stillProcessing(hash: string, depositAddress: string): SubmitResult {
+ return {
+ ok: false,
+ pending: true,
+ error: `Your deposit was sent (${hash}) and the swap is still processing, which can take up to 15 minutes. Don't send it again. ${EXPLORER_NOTE} ${depositAddress}.`,
+ }
+}
+
+function notSeenYet(hash: string, depositAddress: string): SubmitResult {
+ return {
+ ok: false,
+ pending: true,
+ error: `Your wallet returned transaction ${hash}, but neither NEAR nor NEAR Intents has seen it yet. Don't send it again until you've checked ${explorerUrl(hash)}. ${EXPLORER_NOTE} ${depositAddress}.`,
+ }
+}
+
+/**
+ * Tells 1Click about the deposit and waits a bounded time for the outcome.
+ *
+ * A swap still running comes back as `ok: false, pending: true`, with a sentence
+ * saying so. Reporting it as finished would be untrue, and as failed would invite
+ * a second payment.
+ */
+export async function submitOneClick(input: SubmitInput): Promise {
+ const { account, signed, context } = input
+ if (signed.family !== 'near') return { ok: false, error: 'That is not a NEAR transaction.' }
+ const hash = signed.hashes[signed.hashes.length - 1]
+ const depositAddress = context?.['depositAddress']
+ if (hash === undefined || !NEAR_TX_HASH.test(hash)) {
+ return { ok: false, error: 'The wallet did not return a transaction hash to follow.' }
+ }
+ if (depositAddress === undefined || !DEPOSIT_ADDRESS.test(depositAddress)) {
+ return {
+ ok: false,
+ error: `This swap's deposit address was lost, so it can't be followed from here. Your transaction is ${hash}. Check your wallet before trying again.`,
+ }
+ }
+
+ const startedAt = Date.now()
+ const outcome = await waitForTransaction(hash, account, CONFIRM_WAIT_MS)
+ if (outcome.state === 'failed') {
+ return {
+ ok: false,
+ error: `Your deposit transaction failed on NEAR (${hash}), so the funds did not leave your account, apart from the gas. You can try again.`,
+ }
+ }
+ if (outcome.state === 'success') {
+ const paysDeposit = outcome.calls.some(
+ (c) => c.method === 'ft_transfer' && c.args['receiver_id'] === depositAddress
+ )
+ if (outcome.signer !== account || !paysDeposit) {
+ return {
+ ok: false,
+ error: `Transaction ${hash} is not this account's deposit for this swap, so it was not reported to NEAR Intents. Check your wallet's activity.`,
+ }
+ }
+ await notifyDeposit(depositAddress, hash, account)
+ }
+
+ let lastStatus: string | undefined
+ while (Date.now() - startedAt < STATUS_WAIT_MS) {
+ const body = await depositStatus(depositAddress)
+ lastStatus = body?.status ?? lastStatus
+ const recipient = body?.quoteResponse?.quoteRequest?.recipient
+ if (recipient !== undefined && recipient !== account) {
+ return { ok: false, error: 'That deposit address belongs to a different swap.' }
+ }
+ switch (body?.status) {
+ case 'SUCCESS':
+ return { ok: true, hash, explorerUrl: explorerUrl(hash) }
+ case 'REFUNDED':
+ return {
+ ok: false,
+ error: `The swap did not go through, and NEAR Intents refunded your funds to ${account}. Check your balance before trying again.`,
+ }
+ case 'FAILED':
+ return {
+ ok: false,
+ error: `The swap failed after your deposit (${hash}). The refund address is ${account}, so that is where your funds should return. ${EXPLORER_NOTE} ${depositAddress}.`,
+ }
+ case 'INCOMPLETE_DEPOSIT':
+ return {
+ ok: false,
+ error: `NEAR Intents received less than the quote needed, so it won't swap. What arrived is due back at ${account}. ${EXPLORER_NOTE} ${depositAddress}.`,
+ }
+ default:
+ await delay(STATUS_POLL_MS)
+ }
+ }
+ const seen =
+ outcome.state === 'success' || (lastStatus !== undefined && lastStatus !== 'PENDING_DEPOSIT')
+ return seen ? stillProcessing(hash, depositAddress) : notSeenYet(hash, depositAddress)
+}
+
+export const oneClickVenue: ChainVenue = {
+ id: ONECLICK_VENUE_ID,
+ name: 'NEAR Intents 1Click',
+ kinds: ['swap'],
+ prepare: prepareSwap,
+ assertSafe: assertOneClickSafe,
+ submit: submitOneClick,
+}
diff --git a/apps/dapp/lib/chains/near/tokens.ts b/apps/dapp/lib/chains/near/tokens.ts
new file mode 100644
index 00000000..279c01c7
--- /dev/null
+++ b/apps/dapp/lib/chains/near/tokens.ts
@@ -0,0 +1,44 @@
+import type { ChainToken } from '../types'
+
+export const WRAP_NEAR = 'wrap.near'
+export const USDC_NEAR = '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1'
+export const USDT_NEAR = 'usdt.tether-token.near'
+export const RNEAR_CONTRACT = 'lst.rhealab.near'
+export const METAPOOL_CONTRACT = 'meta-pool.near'
+
+/**
+ * Exact NEP-141 accounts. Each id and decimal count was read from the contract's
+ * `ft_metadata` and matches the 1Click token list. `near-contracts.live.test.ts`
+ * re-checks them. NEAR itself is held natively and travels as wNEAR on `wrap.near`.
+ */
+export const nearTokens: readonly ChainToken[] = [
+ { symbol: 'NEAR', id: WRAP_NEAR, decimals: 24, name: 'NEAR' },
+ { symbol: 'USDC', id: USDC_NEAR, decimals: 6, name: 'USD Coin' },
+ { symbol: 'USDT', id: USDT_NEAR, decimals: 6, name: 'Tether USD' },
+]
+
+export const YOCTO_PER_NEAR = BigInt('1000000000000000000000000')
+export const TGAS = BigInt('1000000000000')
+
+export const NEAR_EXPLORER_TX = 'https://nearblocks.io/txns/'
+
+export function explorerUrl(hash: string): string {
+ return `${NEAR_EXPLORER_TX}${hash}`
+}
+
+/** Base units as display text, cut to `places` decimals and never rounded up. */
+export function formatUnits(base: string, decimals: number, places = 6): string {
+ const padded = base.padStart(decimals + 1, '0')
+ const whole = padded.slice(0, padded.length - decimals)
+ const fraction = padded.slice(padded.length - decimals, padded.length - decimals + places)
+ const trimmed = fraction.replace(/0+$/, '')
+ return trimmed === '' ? whole : `${whole}.${trimmed}`
+}
+
+/**
+ * A base-unit amount as a number of display units, for dollar comparisons only.
+ * Never used to build what gets signed.
+ */
+export function toDisplayNumber(base: string, decimals: number): number {
+ return Number(base) / 10 ** decimals
+}
diff --git a/apps/dapp/lib/chains/near/venues.ts b/apps/dapp/lib/chains/near/venues.ts
new file mode 100644
index 00000000..2347e824
--- /dev/null
+++ b/apps/dapp/lib/chains/near/venues.ts
@@ -0,0 +1,151 @@
+import type { Venue } from '@intent/types'
+
+const NEAR = ['NEAR']
+
+/**
+ * The NEAR table from the integration report, in its order. Only what can be
+ * prepared and signed through this app says `executes`; every other row is listed.
+ * Rhea Lend stays listed on purpose, after its April 2026 exploit and at about 86%
+ * stablecoin utilisation. 1Click into Linq stays listed because a failed bank
+ * payout has no clean refund path.
+ */
+export const nearVenues: Venue[] = [
+ {
+ id: '1click',
+ family: 'near',
+ name: 'NEAR Intents 1Click',
+ category: 'swap',
+ chains: NEAR,
+ bestFor: 'Most of the volume on NEAR, priced by competing market makers',
+ url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api',
+ integration: 'executes',
+ capability:
+ 'Swaps NEAR, USDC and USDT inside your NEAR account. Your wallet signs a transfer to a one-time deposit address. Mainnet only. No swap has been signed through this app yet.',
+ },
+ {
+ id: 'near-linq-cashout',
+ family: 'near',
+ name: '1Click into Linq',
+ category: 'offramp',
+ chains: NEAR,
+ bestFor: 'A route from NEAR USDC to naira, untested end to end',
+ url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api',
+ },
+ {
+ id: 'rhea-dex',
+ family: 'near',
+ name: 'Rhea DEX',
+ category: 'swap',
+ chains: NEAR,
+ bestFor: 'The main on-chain DEX on NEAR, for retail-sized trades',
+ url: 'https://rhea.finance',
+ },
+ {
+ id: 'rnear',
+ family: 'near',
+ name: 'rNEAR',
+ category: 'staking',
+ chains: NEAR,
+ bestFor: 'Liquid staking from Rhea, with most supply sitting in Rhea Lend',
+ url: 'https://rhea.finance',
+ integration: 'executes',
+ capability:
+ 'Stakes NEAR for rNEAR on lst.rhealab.near, showing the rate Rhea publishes. Mainnet only. No stake has been signed through this app yet.',
+ },
+ {
+ id: '1click-earn',
+ family: 'near',
+ name: '1Click Earn',
+ category: 'savings',
+ chains: NEAR,
+ bestFor: 'A vault on Base reached by quote, stacking bridge and vault risk',
+ url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/earn',
+ integration: 'executes',
+ capability:
+ 'Puts USDC or USDT into Gauntlet USDC Prime on Base, held in your NEAR Intents balance. Quotes start around $20. This app cannot show or withdraw the position yet. Mainnet only. No deposit has been signed through this app yet.',
+ },
+ {
+ id: 'rhea-lend',
+ family: 'near',
+ name: 'Rhea Lend',
+ category: 'lending',
+ chains: NEAR,
+ bestFor: 'Lending on NEAR. Exploited for about $18.4M in April 2026, and exits can be limited',
+ url: 'https://rhea.finance',
+ },
+ {
+ id: 'metapool',
+ family: 'near',
+ name: 'Meta Pool',
+ category: 'staking',
+ chains: NEAR,
+ bestFor: 'Liquid staking with stNEAR across many validators, from 1 NEAR',
+ url: 'https://metapool.app',
+ integration: 'executes',
+ capability:
+ 'Stakes NEAR for stNEAR on meta-pool.near, from 1 NEAR. Mainnet only. No stake has been signed through this app yet.',
+ },
+ {
+ id: 'near-agent-api',
+ family: 'near',
+ name: 'NEAR Intents Agent API',
+ category: 'infrastructure',
+ chains: NEAR,
+ bestFor: 'Wallets for agents with spending limits, enforced off-chain and with no audit found',
+ url: 'https://docs.agentsonintents.com/integration/agent-api/overview',
+ },
+ {
+ id: 'near-ai-cloud',
+ family: 'near',
+ name: 'NEAR AI Cloud',
+ category: 'infrastructure',
+ chains: NEAR,
+ bestFor: 'Private AI inference in secure hardware, on its higher privacy tiers',
+ url: 'https://docs.near.ai/cloud/quickstart',
+ },
+ {
+ id: 'near-chain-signatures',
+ family: 'near',
+ name: 'Chain Signatures',
+ category: 'infrastructure',
+ chains: NEAR,
+ bestFor: 'Signing for other chains from a NEAR account. Gas on the other chain is still needed',
+ url: 'https://docs.near.org/chain-abstraction/chain-signatures/getting-started',
+ },
+ {
+ id: 'near-confidential-swaps',
+ family: 'near',
+ name: 'Confidential swaps',
+ category: 'swap',
+ chains: NEAR,
+ bestFor: 'A privacy option on 1Click quotes, not yet checked against its originator terms',
+ url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/quickstart/confidential-swaps',
+ },
+ {
+ id: 'near-agent-market',
+ family: 'near',
+ name: 'NEAR AI Agent Market',
+ category: 'payments',
+ chains: NEAR,
+ bestFor: 'Hiring agents and paying per job, with fees not stated on its docs landing page',
+ url: 'https://docs.market.near.ai',
+ },
+ {
+ id: 'near-trezu-sputnik',
+ family: 'near',
+ name: 'Trezu and Sputnik DAO',
+ category: 'savings',
+ chains: NEAR,
+ bestFor: 'Multisig treasuries that a savings group could share',
+ url: 'https://github.com/NEAR-DevHub/trezu',
+ },
+ {
+ id: 'near-hyperliquid',
+ family: 'near',
+ name: 'Hyperliquid via 1Click',
+ category: 'perps',
+ chains: NEAR,
+ bestFor: 'Funding Hyperliquid perps from NEAR. Needs a separate EVM key to trade',
+ url: 'https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes',
+ },
+]
diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts
new file mode 100644
index 00000000..4e822159
--- /dev/null
+++ b/apps/dapp/lib/chains/parse-action.ts
@@ -0,0 +1,70 @@
+import type { ChainActionRequest } from './types'
+
+const AMOUNT = String.raw`\$?([\d,]*\.?\d+)`
+const TOKEN = String.raw`([a-z][a-z0-9.+]{0,11})`
+const VENUE = String.raw`(?:\s+(?:on|via|with|using|in|into|at|to)\s+([a-z0-9][a-z0-9 .-]{1,30}?))?`
+const END = String.raw`\s*[.!]?\s*$`
+
+const SWAP = new RegExp(
+ String.raw`^(?:please\s+)?(?:swap|convert|exchange|trade|change)\s+${AMOUNT}\s*${TOKEN}\s+(?:to|for|into)\s+${TOKEN}${VENUE}${END}`,
+ 'i'
+)
+const SUPPLY = new RegExp(
+ String.raw`^(?:please\s+)?(?:supply|lend|deposit|save|earn on|put)\s+${AMOUNT}\s*${TOKEN}${VENUE}${END}`,
+ 'i'
+)
+const STAKE = new RegExp(
+ String.raw`^(?:please\s+)?(?:liquid\s+)?stake\s+${AMOUNT}\s*${TOKEN}${VENUE}${END}`,
+ 'i'
+)
+
+/** Sentences that ask to do something, so a miss gets told what works rather than a guess. */
+const ACTION_VERB =
+ /\b(swap|convert|exchange|trade|buy|sell|supply|lend|deposit|save|earn|stake|borrow|cash out|send|bridge|long|short)\b/i
+
+function amountOf(text: string): string {
+ return text.replace(/,/g, '')
+}
+
+/**
+ * Reads a plain instruction into an action, or nothing.
+ *
+ * Deliberately a strict shape and not a guess. An instruction that moves money
+ * and is read wrongly costs money, so anything looser falls through to being
+ * told what works. Tokens and venues are checked on the server.
+ */
+export function parseChainAction(text: string): ChainActionRequest | null {
+ const swap = SWAP.exec(text.trim())
+ if (swap !== null) {
+ return {
+ kind: 'swap',
+ amount: amountOf(swap[1] as string),
+ tokenIn: swap[2] as string,
+ tokenOut: swap[3] as string,
+ venue: swap[4]?.trim(),
+ }
+ }
+ const supply = SUPPLY.exec(text.trim())
+ if (supply !== null) {
+ return {
+ kind: 'supply',
+ amount: amountOf(supply[1] as string),
+ tokenIn: supply[2] as string,
+ venue: supply[3]?.trim(),
+ }
+ }
+ const stake = STAKE.exec(text.trim())
+ if (stake !== null) {
+ return {
+ kind: 'stake',
+ amount: amountOf(stake[1] as string),
+ tokenIn: stake[2] as string,
+ venue: stake[3]?.trim(),
+ }
+ }
+ return null
+}
+
+export function looksLikeChainAction(text: string): boolean {
+ return ACTION_VERB.test(text)
+}
diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts
new file mode 100644
index 00000000..a30eac8e
--- /dev/null
+++ b/apps/dapp/lib/chains/prepare.ts
@@ -0,0 +1,92 @@
+import { assertWithinCap, tradeCapUsd, TradeCapExceeded } from '../server/trade-cap'
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ type ChainActionRequest,
+ type ChainModule,
+ type ChainVenue,
+ type PreparedAction,
+} from './types'
+
+function pickVenue(chainApi: ChainModule, request: ChainActionRequest): ChainVenue {
+ const usable = chainApi.venues.filter((v) => v.kinds.includes(request.kind))
+ if (request.venue !== undefined) {
+ const wanted = request.venue.trim().toLowerCase()
+ const named = usable.find(
+ (v) => v.id.toLowerCase() === wanted || v.name.toLowerCase().includes(wanted)
+ )
+ if (named === undefined) {
+ const names = usable.map((v) => v.name).join(', ')
+ throw new ChainActionRefused(
+ names === ''
+ ? `Nothing here can ${request.kind} on this chain yet.`
+ : `"${request.venue}" can't ${request.kind} here. Available: ${names}.`
+ )
+ }
+ return named
+ }
+ const defaultId = chainApi.defaultVenue[request.kind]
+ const venue = usable.find((v) => v.id === defaultId) ?? usable[0]
+ if (venue === undefined) {
+ throw new ChainActionRefused(`Nothing here can ${request.kind} on this chain yet.`)
+ }
+ return venue
+}
+
+/**
+ * Prepares an action, with the same order of checks as every other money route:
+ * known tokens, a dollar value under the cap, then the build, then a check of
+ * what the venue's API returned before anyone is asked to sign it.
+ *
+ * The cap always reads as mainnet, because these chains only run on mainnet. The
+ * Stellar network a deployment defaults to says nothing about Solana's funds.
+ */
+export async function prepareChainAction(
+ chainApi: ChainModule,
+ request: ChainActionRequest,
+ account: string,
+ env: Record = process.env
+): Promise {
+ const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn)
+ if (tokenIn === undefined) {
+ throw new ChainActionRefused(
+ `I can't confirm ${request.tokenIn} as a verified token on this chain, so I won't touch it.`
+ )
+ }
+ if (request.kind === 'swap') {
+ if (
+ request.tokenOut === undefined ||
+ tokenBySymbol(chainApi.tokens, request.tokenOut) === undefined
+ ) {
+ throw new ChainActionRefused(
+ `I can't confirm ${request.tokenOut ?? 'that token'} as a verified token on this chain, so I won't trade it.`
+ )
+ }
+ if (request.tokenOut.toUpperCase() === request.tokenIn.toUpperCase()) {
+ throw new ChainActionRefused('Those are the same token.')
+ }
+ }
+
+ const price = await chainApi.priceUsd(tokenIn)
+ const amount = Number(request.amount)
+ if (!Number.isFinite(amount) || amount <= 0) {
+ throw new ChainActionRefused(`"${request.amount}" is not an amount.`)
+ }
+ // The cap's own wording is for whoever runs the deployment, so a person gets this instead.
+ const usd = price === undefined ? Number.NaN : amount * price
+ try {
+ assertWithinCap(usd, env, 'mainnet')
+ } catch (e) {
+ if (!(e instanceof TradeCapExceeded)) throw e
+ throw new ChainActionRefused(
+ Number.isFinite(usd)
+ ? `This app caps each trade at ${tradeCapUsd(env, 'mainnet')}, and this one is about ${usd.toFixed(2)}. Try a smaller amount.`
+ : 'This trade could not be valued in dollars right now, so it was not built. Try again shortly.'
+ )
+ }
+
+ const venue = pickVenue(chainApi, request)
+ const prepared = await venue.prepare(request, account)
+ venue.assertSafe(prepared.signing, account)
+ return prepared
+}
diff --git a/apps/dapp/lib/chains/registry.ts b/apps/dapp/lib/chains/registry.ts
new file mode 100644
index 00000000..9df5fb47
--- /dev/null
+++ b/apps/dapp/lib/chains/registry.ts
@@ -0,0 +1,16 @@
+import { nearModule } from './near/module'
+import { solanaModule } from './solana/module'
+import { suiModule } from './sui/module'
+import type { ChainModule, ChainWithActions } from './types'
+
+const MODULES: Record = {
+ solana: solanaModule,
+ sui: suiModule,
+ near: nearModule,
+}
+
+export function chainModule(chain: string): ChainModule | undefined {
+ return Object.prototype.hasOwnProperty.call(MODULES, chain)
+ ? MODULES[chain as ChainWithActions]
+ : undefined
+}
diff --git a/apps/dapp/lib/chains/solana/__fixtures__/captured-transactions.ts b/apps/dapp/lib/chains/solana/__fixtures__/captured-transactions.ts
new file mode 100644
index 00000000..5720639f
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/__fixtures__/captured-transactions.ts
@@ -0,0 +1,32 @@
+/**
+ * Unsigned transactions as the venues' live APIs returned them on 3 November 2026
+ * for a public exchange wallet. Nothing here was ever signed or sent. These are
+ * test inputs, kept real so the safety check is tested against what venues send
+ * and not against what this code assumes they send.
+ */
+
+export const CAPTURE_ACCOUNT = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+
+/** SOL to USDC: wraps SOL, syncs, swaps through Jupiter, closes the wrapped account. */
+export const JUPITER_SOL_TO_USDC =
+ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAECu++/MfE4AbxQjbuj9TbrI0Pxhnp8RvabHggIBH0XeDhKqvyIbZix+fpRGSbB/fJ9qlw8YrXSMI7WJxKO1YiAxJGu858PHSn01DRqu+2xZcTQZOKdKyyOsBAX1pU7B2iBE/IePkxuR0MkiLLQWlu1fXxF0LWh5hM7CrZfiXFMJ5KYnnpekGicjuHfSvhLANqgdTYqfhywU8WHMfGqj4UFYOmucJNLhhbjK93qZrJROadpvcRLp5gZgXmHPDDDtorLgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpw+kxbqIJwULwckPMFsbIC1wJx+yNi0LsX0rymLoqA4IGBwAFAtZDBAAHAAkDUcMAAAAAAAAGAgAFDAIAAACAlpgAAAAAAAkBBQERCEQeAAUKCwQgHQkJHAgKJh4pFQoLFhIUCQMTCCoJHhkKGAEbAhoXKyUeJw8BCxEQERAQCSgIJCIMHg0OCgsgHQkJKCMhHzrRmFOTfP7Y6QiAlpgAAAAAAPw2DgAAAAAAZAACAAAABAAAABqCAQAEEQG5BwACaAEQJwIEsgHVHQAECQMFAAABCQUpv5UHKk+/BN9xdZPnOLzMIScoFcJHGoDZ+fVVsNSEJQIgMQUAKAsCF3tKctFGnd1tHsKxn5Pf2tudCkwiJlmbEjQyeX3grb1/A6ipoASloqSh4k8+KpZXHoZ2stouSFQQDE0nTzzoyEvg3OKGj8kaqe0DAQQGBAIhAAP0xRH0PrrZmAHeGzYZ8wB4mclMg7XarPjVJOnwvLEV6gXP0s7U0QJCivx+A4qngq/9sNoG5zIbghwdhE6XEmk9n66iHREm1eyrBU9OTFJLAU0='
+
+/** USDC to ONyc: creates the output token account first. */
+export const JUPITER_USDC_TO_ONYC =
+ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAFC+++/MfE4AbxQjbuj9TbrI0Pxhnp8RvabHggIBH0XeDhOtNtMQOXPbPyTOQ3uVSfsvNj/XyWsznd70LFD6r5MhBcaEnwqOgzV2KjmpNiMtN3JASkRu5y9unkF6vJ0QaMvWJ56XpBonI7h30r4SwDaoHU2Kn4csFPFhzHxqo+FBWDfWa8n/sjTT3HI/IwQ4pR4U88qPUaN9kguJRZN0WH4YDSiV/TbhTDRMOwKN1bke7yC3isxh/No5hNOokulLg8wkNoFiav6tQ+huksLT4PpjXVaKG96BuJ8Jjaesbl2fGujJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPBUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI13uiymtpFOKS82yA42y/+7PT57bSSCgyTw5d+v7yg0DgQIAAUCrv4AAAgACQMaBgAAAAAAAAcGAAIABhAUAQEJHAADAhIGFBQJEQkLFRQUCgANBhICDAMPAQQFDhMpu2T6zDHErxSAlpgAAAAAAOHjAwMCAAAAZAAKAAAAAQAAAC8AABAnAAECKb+VBypPvwTfcXWT5zi8zCEnKBXCRxqA2fn1VbDUhCUBOAUTACgCFN5wEENPp89TUAxOpy1zYNrOB/7FzmGc8ntRFreUUQ+NBMDExsgBww=='
+
+/** 1 USDC into Jupiter Lend Earn. A legacy transaction with no lookup tables. */
+export const JUPITER_LEND_USDC_DEPOSIT =
+ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAkS7778x8TgBvFCNu6P1NusjQ/GGenxG9pseCAgEfRd4OEcj71J70/rvu0J/yTO1674UGxqeljWsbxMzyBLGGTIhWJ56XpBonI7h30r4SwDaoHU2Kn4csFPFhzHxqo+FBWDZfXf/XyEr3Wopfe9zqFiLis6v4f8U6hxuQhWGjqLG95w0SUk4+ambDxELpSPv0i8na4KCHwQKxINHvZZBGNKCQIRE+8/cf8FyV6ejOFzFvFRe8v54cSEIbvrhfgwTQQYeXyzPK1sDIBzsh2kxDdkBYXtOfawRnLIelHWBYy3JvqgC5ZIwUW4+UWatmCd5Vc8UzUhP8EscD1FvC2CJQgA1veA4oganO3BaPj9y3TzLqfiwk9dDCxaIuRcmdJ+MiQSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABHJ/EQ+a2cgo929kauPWYlbJknEb75921PaoQRwAgT1Uepp778BbCkWRDvAOfH/e3tf8kYkOWoSREcIpwuparJSaJkbJuRq/cvVvsHRwHJwyp3lX50EzExEKvsHW7PCICMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4Wcb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hCv4bkS5IXpX9FespN9/8SzejFtCmOBL/ArpJtMbBjR4K/h6X5FbLuS5kKiW1C6Y5b/JIFkvKdGljqEWSrNbMwwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpD6sWqcS59HX7KAY9LHOtogrmRQvGjlC1dABpsG3ykuYBDxEAAgQOCgEGBQgLBwMQDBENCRDyI8aJUuHytkBCDwAAAAAA'
+
+/** 1 USDC into Kamino's RWA USDC vault. */
+export const KAMINO_USDC_DEPOSIT =
+ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAGDe++/MfE4AbxQjbuj9TbrI0Pxhnp8RvabHggIBH0XeDhYnnpekGicjuHfSvhLANqgdTYqfhywU8WHMfGqj4UFYOQ7zx5Ah0IpelvjEUQvjZWBi9vs7ZIRrHU16rvn+Q/p593nOfJI1vBUY6a2s6XkE7VEOnc3+5jt2MWHKFrqanIn3smqAYm5kQf+NSirZZF1P3vhH/dQjTzIgsYAoJfWailJ7myoZzVt+kLjkLAIUXyeewJc8Yeo6y4jCsS9d6Y89tltBllnPDqDR3kR8IuaSZC8JDw7qTheMgAHEg16bTIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP2mzp9mGY3YcNiphvMGdcRvg+L1KDSHlS9dUFY+PveoyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZ2LAQF2PT5R8SbmFW3oXejGEwWbhEaNDaP+iioiUcxwH//iUCQ0n+fUW3Fp7JSxad+YkEMDBMChQo53FWex1jnATZCvHbiTnsNf+U2WQNV9zDJ5S7Phu2b9OuhJqE3NuUBDxz7qrp+R533tzPf3lhdf6i16KVzpirv5c6JP447BMECQYAAgAWByEBAQwfABcQHh0WAQIfISEIDAQOFRIRDxQGBQsaGRwZGxgLGhDyI8aJUuHytkBCDwAAAAAACggAAAAAAxMHIAhvEbn6PHom/goIAAMTDQIWCiEQzrDKEsjRs2z//////////wHtn8MZ9+CC+/ECP/gV81XL4YqbMhm1fZnhLYmMIfk/3gsMEkoPPBYLQhQFAQpEIh1MLQQCBwYD'
+
+/** A DFlow route Jupiter offered, which the swap venue excludes. */
+export const JUPITER_DFLOW_ROUTE =
+ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADC+++/MfE4AbxQjbuj9TbrI0Pxhnp8RvabHggIBH0XeDhH9RsYXp41YrTtfB/VqrENVYdGHG6rtaCOqWfAPswrbhC8ZwMiqMl6l85DmceFH8qGtYH5eGHuvAXsNEOb+fd72J56XpBonI7h30r4SwDaoHU2Kn4csFPFhzHxqo+FBWDdRfVFDbhpBAX6imwWnO15pFJg43+DfWUVmLU0CWSwtqNcFgNh9XyZZ8JwhkCJAsOnOy7qkVmNK0R14sy3/PFV6a5wk0uGFuMr3epmslE5p2m9xEunmBmBeYc8MMO2isu/Ysfn7kyvfTZtOuMngVs1tXz0/w347l+fAmhJW1uCRMDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAALXjShTivHNIaQ7h9a9d7tZVOECjbaq4YLBQYHO9wDwQCvHDQyGIyjpjUTWhOhiVGs69KeasLu04+R5t0bI7PFdFSZaDIWXjnVMVuDRi/E3ui6qenm9o9WjjOC6uxe2vkQQIAAUCBPgDAAgACQPVCwAAAAAAAAkvJx0aABwJGyYoCQkKJAAXCwMCDREQIyAhGQ4MDx8iHgAYFAIGExUWJwcpBQQlARKTAfjGnpHhdYfIBQAAACXfEmSRrc2DTbpb0vxgz0fMHTAmcwy7H0Sr31LpOp9rYFJ9pNdKP8rcHJ5CrFGdAy4aTYKHwC116XeCfvsin0APd5q2GwAAjOuTAAAAMQIAAAB6Pw8AAAAAAAbuLKIAAAAAAAYtRUBLTAAAAAAABQEFgWRMAAAAAAADAWZPMgMAAAAAZAACAAkDAAYnCGMoDmkta6zJA1cPPg55vqmZW7U8ku+2PUBhjd1ylWorIh84xJ3sZcNOBy00LzYxMCwDOGM1g4sL5A9FV+sVlm7jTSBReURrdqAXwtC1s9CQorJ6bPwB7g8A3goHFFW5ulYIKLgBBQa8BFFfOrbTCK3lWUUlsEeg5zyah4CusQKU0vtrhWvZCQRSTlFTAVQ='
+
+/** 1 USDC into Lulo Protected. */
+export const LULO_USDC_DEPOSIT =
+ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQACB+++/MfE4AbxQjbuj9TbrI0Pxhnp8RvabHggIBH0XeDhYnnpekGicjuHfSvhLANqgdTYqfhywU8WHMfGqj4UFYO0X/bTDqnW4dS9s1g25C0cvT50BYA9+/sxvki18EdyFiUTTjD6XQueKYcbH1Da2F3mFup8p2oFpFghsW/cyIhU080OLDGFo9pjtWm3dvXgr3lcYxWyrp45F3iT2tOcpjsDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAANTkf8v62yBcxOxUimaJnOIVYNQPcQ87pZ3HbK4vQGOTmVr63T9g95465Y7NTNH16w9s3SGsfyL0dcBMHViOSfEDBQAFAlZRAwAFAAkD1FkAAAAAAAAGEwAACgEHCAkCAwsMBg0ODwQIEBEQt9hL4v6Q1AdAQg8AAAAAAAF2cKum+qVKfLbs0u16J00fJ6Ie+zMh50x4Fr9oSyOgzAMTDQsIDxAVERQWx8g='
diff --git a/apps/dapp/lib/chains/solana/assert-safe.ts b/apps/dapp/lib/chains/solana/assert-safe.ts
new file mode 100644
index 00000000..7bcc3dff
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/assert-safe.ts
@@ -0,0 +1,300 @@
+import { createHash } from 'node:crypto'
+
+import {
+ getAddressDecoder,
+ getAddressEncoder,
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getTransactionDecoder,
+ isAddress,
+ isOffCurveAddress,
+ type Address,
+} from '@solana/kit'
+
+import { bytesFromBase64 } from '../base64'
+import { ChainActionRefused, type SigningPayload } from '../types'
+
+export const SYSTEM_PROGRAM = '11111111111111111111111111111111'
+export const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
+export const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'
+const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'
+const WRAPPED_SOL_MINT = 'So11111111111111111111111111111111111111112'
+
+/**
+ * A priority fee is paid in SOL by the fee payer and nothing else caps it, so a
+ * hostile API could spend a wallet's SOL through one. Live routes on 3 November
+ * 2026 paid up to about 40,000 lamports. This leaves room for congestion at about
+ * 0.005 SOL.
+ */
+const MAX_PRIORITY_FEE_LAMPORTS = BigInt(5_000_000)
+/** What the network charges compute for when a transaction sets no limit of its own. */
+const DEFAULT_COMPUTE_UNIT_LIMIT = BigInt(1_400_000)
+
+export interface SolanaVenuePolicy {
+ venueName: string
+ /** Program id to the discriminators it may be called with. None listed means any. */
+ programs: Readonly>
+ /** Whether wrapping SOL (a transfer to the account's own wrapped SOL account) is expected. */
+ mayWrapSol: boolean
+}
+
+interface DecodedInstruction {
+ programIndex: number
+ accounts: readonly number[]
+ data: Uint8Array
+}
+
+export interface DecodedSolanaTransaction {
+ feePayer: string
+ /** The fee payer's signature bytes, or null while unsigned. */
+ feePayerSignature: Uint8Array | null
+ blockhash: string
+ signerCount: number
+ readonlySignerCount: number
+ staticAccounts: readonly string[]
+ instructions: readonly DecodedInstruction[]
+}
+
+function refuse(sentence: string): never {
+ throw new ChainActionRefused(
+ `${sentence} I refused it, so nothing was signed or sent. Try again, or use another venue if it keeps happening.`
+ )
+}
+
+export function decodeSolanaTransaction(base64: string): DecodedSolanaTransaction {
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) refuse('That transaction could not be read.')
+ let decoded: DecodedSolanaTransaction | undefined
+ try {
+ const bytes = bytesFromBase64(base64)
+ const transaction = getTransactionDecoder().decode(bytes)
+ const message = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes)
+ // Anything after the message would be bytes a wallet shows nobody. The decoder keeps
+ // them as message bytes and ignores them, so re-encoding is what reveals them.
+ const encoded = getCompiledTransactionMessageEncoder().encode(message)
+ const exact =
+ encoded.length === transaction.messageBytes.length &&
+ encoded.every((byte, index) => byte === transaction.messageBytes[index])
+ if (exact && (message.version === 'legacy' || message.version === 0)) {
+ const feePayer = message.staticAccounts[0] as string
+ decoded = {
+ feePayer,
+ feePayerSignature: transaction.signatures[feePayer as Address] ?? null,
+ blockhash: message.lifetimeToken,
+ signerCount: message.header.numSignerAccounts,
+ readonlySignerCount: message.header.numReadonlySignerAccounts,
+ staticAccounts: message.staticAccounts,
+ instructions: message.instructions.map((instruction) => ({
+ programIndex: instruction.programAddressIndex,
+ accounts: instruction.accountIndices ?? [],
+ data: Uint8Array.from(instruction.data ?? []),
+ })),
+ }
+ }
+ } catch {
+ refuse('That transaction could not be read.')
+ }
+ return decoded ?? refuse('That transaction is in a form I can’t check.')
+}
+
+/**
+ * The owner's token account for a mint. Written out rather than taken from the
+ * kit's async derivation because `assertSafe` is synchronous by contract.
+ */
+export function associatedTokenAccount(owner: string, mint: string): string {
+ const encoder = getAddressEncoder()
+ const decoder = getAddressDecoder()
+ const seeds = [
+ encoder.encode(owner as Address),
+ encoder.encode(TOKEN_PROGRAM as Address),
+ encoder.encode(mint as Address),
+ ]
+ const program = encoder.encode(ASSOCIATED_TOKEN_PROGRAM as Address)
+ for (let bump = 255; bump >= 0; bump -= 1) {
+ const hash = createHash('sha256')
+ for (const seed of seeds) hash.update(Uint8Array.from(seed))
+ hash.update(Uint8Array.of(bump))
+ hash.update(Uint8Array.from(program))
+ hash.update('ProgramDerivedAddress')
+ const candidate = decoder.decode(hash.digest())
+ if (isOffCurveAddress(candidate)) return candidate
+ }
+ throw new Error('No program address for this owner.')
+}
+
+export function ownWrappedSolAccount(owner: string): string {
+ return associatedTokenAccount(owner, WRAPPED_SOL_MINT)
+}
+
+function hex(bytes: Uint8Array): string {
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
+}
+
+function readLittleEndian(data: Uint8Array, offset: number, length: number): bigint {
+ let value = BigInt(0)
+ for (let i = length - 1; i >= 0; i -= 1) {
+ value = (value << BigInt(8)) | BigInt(data[offset + i] ?? 0)
+ }
+ return value
+}
+
+/** The address at an account position, or nothing if it sits in a lookup table. */
+function staticAccount(
+ transaction: DecodedSolanaTransaction,
+ instruction: DecodedInstruction,
+ position: number
+): string | undefined {
+ const index = instruction.accounts[position]
+ return index === undefined ? undefined : transaction.staticAccounts[index]
+}
+
+function checkComputeBudget(instructions: readonly DecodedInstruction[]): void {
+ let price = BigInt(0)
+ let limit = DEFAULT_COMPUTE_UNIT_LIMIT
+ for (const { data } of instructions) {
+ const tag = data[0]
+ if (tag === 2 && data.length === 5) limit = readLittleEndian(data, 1, 4)
+ else if (tag === 3 && data.length === 9) price = readLittleEndian(data, 1, 8)
+ else if (!(tag === 4 && data.length === 5)) {
+ refuse('This transaction sets a compute budget option I don’t recognise.')
+ }
+ }
+ if ((price * limit) / BigInt(1_000_000) > MAX_PRIORITY_FEE_LAMPORTS) {
+ refuse('This transaction would pay an unusually large priority fee from your SOL.')
+ }
+}
+
+function checkSystem(
+ transaction: DecodedSolanaTransaction,
+ instruction: DecodedInstruction,
+ account: string,
+ policy: SolanaVenuePolicy
+): void {
+ const { data } = instruction
+ const isTransfer = data.length === 12 && readLittleEndian(data, 0, 4) === BigInt(2)
+ if (!isTransfer) refuse('This transaction asks the System Program to do something unexpected.')
+ const from = staticAccount(transaction, instruction, 0)
+ const to = staticAccount(transaction, instruction, 1)
+ if (!policy.mayWrapSol || from !== account || to !== ownWrappedSolAccount(account)) {
+ refuse('This transaction includes a SOL transfer that does not stay in your own account.')
+ }
+}
+
+function checkToken(
+ transaction: DecodedSolanaTransaction,
+ instruction: DecodedInstruction,
+ account: string,
+ policy: SolanaVenuePolicy
+): void {
+ const { data } = instruction
+ const tag = data.length === 1 ? data[0] : undefined
+ const own = policy.mayWrapSol ? ownWrappedSolAccount(account) : undefined
+ const target = staticAccount(transaction, instruction, 0)
+ if (tag === 17) {
+ if (own === undefined || target !== own) {
+ refuse('This transaction touches a token account it should not.')
+ }
+ return
+ }
+ if (tag === 9) {
+ const destination = staticAccount(transaction, instruction, 1)
+ const owner = staticAccount(transaction, instruction, 2)
+ if (own === undefined || target !== own || destination !== account || owner !== account) {
+ refuse('This transaction would close a token account in a way that does not pay you back.')
+ }
+ return
+ }
+ refuse('This transaction moves tokens outside the venue you asked for.')
+}
+
+function checkAssociatedToken(
+ transaction: DecodedSolanaTransaction,
+ instruction: DecodedInstruction,
+ account: string
+): void {
+ const { data } = instruction
+ const createsAccount =
+ data.length === 0 || (data.length === 1 && (data[0] === 0 || data[0] === 1))
+ if (!createsAccount) refuse('This transaction does something unexpected with token accounts.')
+ const payer = staticAccount(transaction, instruction, 0)
+ const owner = staticAccount(transaction, instruction, 2)
+ if (payer !== account || owner !== account) {
+ refuse('This transaction creates a token account that is not yours.')
+ }
+}
+
+/**
+ * Checks what a transaction does at its top level.
+ *
+ * What this cannot see: what an allowed program does inside, and any account
+ * behind a lookup table, because resolving one needs the network and `assertSafe`
+ * is synchronous. So a transfer's source and destination must be plain addresses,
+ * and how much a route returns rests on the venue's program and the slippage bound.
+ */
+export function solanaAssertSafe(policy: SolanaVenuePolicy) {
+ return (payload: SigningPayload, account: string): void => {
+ if (payload.family !== 'solana') return refuse('That is not a Solana transaction.')
+ if (!isAddress(account)) refuse('That is not a Solana wallet address.')
+
+ const transaction = decodeSolanaTransaction(payload.transaction)
+ if (transaction.feePayer !== account) {
+ refuse(`This transaction’s network fee would not be paid by your wallet, ${account}.`)
+ }
+ if (transaction.signerCount !== 1 || transaction.readonlySignerCount !== 0) {
+ refuse('This transaction needs a signature from someone other than you.')
+ }
+
+ const budget: DecodedInstruction[] = []
+ for (const instruction of transaction.instructions) {
+ const program = transaction.staticAccounts[instruction.programIndex]
+ if (program === undefined) {
+ refuse('This transaction calls a program hidden in a lookup table.')
+ }
+ if (program === COMPUTE_BUDGET_PROGRAM) budget.push(instruction)
+ else if (program === SYSTEM_PROGRAM) checkSystem(transaction, instruction, account, policy)
+ else if (program === TOKEN_PROGRAM) checkToken(transaction, instruction, account, policy)
+ else if (program === ASSOCIATED_TOKEN_PROGRAM) {
+ checkAssociatedToken(transaction, instruction, account)
+ } else {
+ const rule = policy.programs[program as string]
+ if (rule === undefined) {
+ refuse(`This transaction calls a program ${policy.venueName} should not need.`)
+ } else if (
+ rule.discriminators !== undefined &&
+ !rule.discriminators.includes(hex(instruction.data.subarray(0, 8)))
+ ) {
+ refuse(`This transaction asks ${policy.venueName} for an instruction I didn’t expect.`)
+ }
+ }
+ }
+ checkComputeBudget(budget)
+ }
+}
+
+/**
+ * Binds a deposit to the amount asked for. The first eight bytes of the instruction
+ * are its discriminator and the next eight the amount in base units.
+ */
+export function assertDepositAmount(base64: string, program: string, baseUnits: string): void {
+ const transaction = decodeSolanaTransaction(base64)
+ const deposits = transaction.instructions.filter(
+ (instruction) => transaction.staticAccounts[instruction.programIndex] === program
+ )
+ const [deposit] = deposits
+ if (deposits.length !== 1 || deposit === undefined || deposit.data.length !== 16) {
+ refuse('This transaction is not the single deposit you asked for.')
+ } else if (readLittleEndian(deposit.data, 8, 8) !== BigInt(baseUnits)) {
+ refuse('This deposit’s amount is not the amount you asked for.')
+ }
+}
+
+/** Bounds SOL moved into the wrapped account by a swap to what the swap spends. */
+export function assertWrapAtMost(base64: string, lamports: bigint): void {
+ const transaction = decodeSolanaTransaction(base64)
+ let wrapped = BigInt(0)
+ for (const instruction of transaction.instructions) {
+ if (transaction.staticAccounts[instruction.programIndex] === SYSTEM_PROGRAM) {
+ wrapped += readLittleEndian(instruction.data, 4, 8)
+ }
+ }
+ if (wrapped > lamports) refuse('This swap would wrap more SOL than you asked to spend.')
+}
diff --git a/apps/dapp/lib/chains/solana/capabilities.ts b/apps/dapp/lib/chains/solana/capabilities.ts
new file mode 100644
index 00000000..3c02d000
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/capabilities.ts
@@ -0,0 +1,7 @@
+import type { ChainCapability } from '../explain'
+
+/** What actually works here, for telling someone who asked for something else. Client-safe. */
+export const solanaCapabilities: ChainCapability[] = [
+ { kind: 'swap', venue: 'Jupiter', symbol: 'SOL' },
+ { kind: 'supply', venue: 'Jupiter Lend', symbol: 'USDC' },
+]
diff --git a/apps/dapp/lib/chains/solana/http.ts b/apps/dapp/lib/chains/solana/http.ts
new file mode 100644
index 00000000..53e9165e
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/http.ts
@@ -0,0 +1,72 @@
+import { ChainActionRefused } from '../types'
+
+const DEFAULT_TIMEOUT_MS = 15_000
+/** Jupiter allows one keyless request every two seconds. */
+const DEFAULT_RETRY_DELAY_MS = 2_100
+
+export interface VenueRequest {
+ /** Named in what the user reads, such as "Jupiter". */
+ label: string
+ method?: 'GET' | 'POST'
+ headers?: Record
+ body?: unknown
+ timeoutMs?: number
+ retryDelayMs?: number
+}
+
+export interface VenueResponse {
+ status: number
+ /** The parsed body, or undefined if it was not JSON. */
+ body: unknown
+}
+
+function wait(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/**
+ * A venue API call made while building an action, when nothing has been signed.
+ * Being unreachable or rate limited is a refusal the user can act on. Other
+ * statuses go back to the caller, who knows what they mean for that venue.
+ */
+export async function venueFetch(url: string, request: VenueRequest): Promise {
+ const { label, method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = request
+ const send = async (): Promise => {
+ try {
+ return await fetch(url, {
+ method,
+ headers: body === undefined ? headers : { 'content-type': 'application/json', ...headers },
+ signal: AbortSignal.timeout(timeoutMs),
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ })
+ } catch {
+ throw new ChainActionRefused(
+ `${label} didn't answer in time. Nothing was signed or sent, so try again in a moment.`
+ )
+ }
+ }
+
+ let response = await send()
+ if (response.status === 429) {
+ await wait(request.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS)
+ response = await send()
+ }
+ if (response.status === 429) {
+ throw new ChainActionRefused(
+ `${label} is busy right now. Nothing was signed or sent, so try again in a few seconds.`
+ )
+ }
+ const text = await response.text().catch(() => '')
+ try {
+ return { status: response.status, body: JSON.parse(text) }
+ } catch {
+ return { status: response.status, body: undefined }
+ }
+}
+
+export function jupiterHeaders(env: Record): Record {
+ const key = env['JUPITER_API_KEY']?.trim()
+ return key === undefined || key === '' ? {} : { 'x-api-key': key }
+}
+
+export const JUPITER_API = 'https://api.jup.ag'
diff --git a/apps/dapp/lib/chains/solana/jupiter-lend.ts b/apps/dapp/lib/chains/solana/jupiter-lend.ts
new file mode 100644
index 00000000..231afc39
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/jupiter-lend.ts
@@ -0,0 +1,203 @@
+import {
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getTransactionDecoder,
+ isAddress,
+ type Address,
+} from '@solana/kit'
+import { z } from 'zod'
+
+import { base64FromBytes, bytesFromBase64 } from '../base64'
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+} from '../types'
+import {
+ assertDepositAmount,
+ ASSOCIATED_TOKEN_PROGRAM,
+ associatedTokenAccount,
+ solanaAssertSafe,
+ SYSTEM_PROGRAM,
+ TOKEN_PROGRAM,
+} from './assert-safe'
+import { JUPITER_API, jupiterHeaders, venueFetch } from './http'
+import { JUPITER_LEND_POLICY, JUPITER_LEND_PROGRAM } from './programs'
+import { SOLANA_TOKENS } from './tokens'
+
+/**
+ * Only tokens the wallet already holds as a token account. Its SOL deposit spends
+ * from a wrapped SOL account and does not wrap native SOL first, as a live
+ * transaction showed, so it would fail for most wallets.
+ */
+const SUPPORTED = new Set(['USDC', 'USDT'])
+
+const Rates = z.array(
+ z.object({
+ assetAddress: z.string(),
+ supplyRate: z.union([z.string(), z.number()]).optional(),
+ rewardsRate: z.union([z.string(), z.number()]).optional(),
+ })
+)
+
+const Deposit = z.object({ transaction: z.string().min(1) })
+
+/**
+ * Percent from a rate. The live API returns whole basis points ("504"), though its
+ * docs show a fraction ("0.025"), so both are read, and anything else is no rate.
+ */
+function ratePercent(rate: string | number | undefined): number | undefined {
+ if (rate === undefined || rate === '') return undefined
+ const value = Number(rate)
+ if (!Number.isFinite(value) || value < 0) return undefined
+ const percent = String(rate).includes('.') || value < 1 ? value * 100 : value / 100
+ return percent <= 100 ? percent : undefined
+}
+
+const CREATE_IDEMPOTENT = 1
+/** Where the deposit instruction lists the receipt token account and its mint. */
+const RECIPIENT_POSITION = 2
+const RECEIPT_MINT_POSITION = 6
+
+/**
+ * Puts a step in front of the deposit that opens the wallet's receipt token account.
+ *
+ * The deposit Jupiter's API returns assumes this account exists. For a first-time
+ * depositor it doesn't, and a read-only simulation on 3 November 2026 failed with
+ * `AccountNotInitialized` on `recipient_token_account`. Creating it this way is a
+ * no-op when it already exists. Its accounts were all listed by the deposit, so
+ * nothing new needs a signature, and the message stays unsigned until the wallet.
+ */
+export function withReceiptAccount(base64: string, account: string): string {
+ const { messageBytes } = getTransactionDecoder().decode(bytesFromBase64(base64))
+ const message = getCompiledTransactionMessageDecoder().decode(messageBytes)
+ if (message.version !== 'legacy' && message.version !== 0) return base64
+ const { staticAccounts, instructions } = message
+ const programAt = (index: number): string | undefined => staticAccounts[index]
+ if (instructions.some((i) => programAt(i.programAddressIndex) === ASSOCIATED_TOKEN_PROGRAM)) {
+ return base64
+ }
+
+ const deposit = instructions.find(
+ (i) => programAt(i.programAddressIndex) === JUPITER_LEND_PROGRAM
+ )
+ const recipientIndex = deposit?.accountIndices?.[RECIPIENT_POSITION]
+ const mintIndex = deposit?.accountIndices?.[RECEIPT_MINT_POSITION]
+ const recipient = recipientIndex === undefined ? undefined : staticAccounts[recipientIndex]
+ const mint = mintIndex === undefined ? undefined : staticAccounts[mintIndex]
+ if (
+ recipientIndex === undefined ||
+ mintIndex === undefined ||
+ recipient === undefined ||
+ mint === undefined ||
+ recipient !== associatedTokenAccount(account, mint)
+ ) {
+ throw new ChainActionRefused(
+ 'This deposit would not pay into your own token account, so I refused it. Nothing was signed or sent.'
+ )
+ }
+
+ const indexOf = (address: string): number => staticAccounts.indexOf(address as Address)
+ const programAddressIndex = indexOf(ASSOCIATED_TOKEN_PROGRAM)
+ const systemIndex = indexOf(SYSTEM_PROGRAM)
+ const tokenIndex = indexOf(TOKEN_PROGRAM)
+ if (programAddressIndex === -1 || systemIndex === -1 || tokenIndex === -1) {
+ throw new ChainActionRefused(
+ 'Jupiter Lend returned a deposit I can’t complete, so nothing was signed or sent. Try again later.'
+ )
+ }
+ const create = {
+ programAddressIndex,
+ accountIndices: [0, recipientIndex, 0, mintIndex, systemIndex, tokenIndex],
+ data: Uint8Array.of(CREATE_IDEMPOTENT),
+ }
+ const bytes = getCompiledTransactionMessageEncoder().encode({
+ ...message,
+ instructions: [create, ...instructions],
+ })
+ const unsigned = new Uint8Array(1 + 64 + bytes.length)
+ unsigned[0] = 1
+ unsigned.set(bytes, 65)
+ return base64FromBytes(unsigned)
+}
+
+export interface JupiterLendOptions {
+ env?: Record
+ retryDelayMs?: number
+}
+
+export function createJupiterLendVenue(options: JupiterLendOptions = {}): ChainVenue {
+ const env = options.env ?? process.env
+ const shared = {
+ label: 'Jupiter',
+ headers: jupiterHeaders(env),
+ ...(options.retryDelayMs !== undefined ? { retryDelayMs: options.retryDelayMs } : {}),
+ }
+
+ async function readApy(mint: string): Promise {
+ const { status, body } = await venueFetch(`${JUPITER_API}/lend/v1/earn/tokens`, shared).catch(
+ () => ({ status: 0, body: undefined })
+ )
+ const rates = Rates.safeParse(body)
+ if (status !== 200 || !rates.success) return undefined
+ const entry = rates.data.find((r) => r.assetAddress === mint)
+ const basePercent = ratePercent(entry?.supplyRate)
+ if (basePercent === undefined) return undefined
+ const rewardPercent = ratePercent(entry?.rewardsRate)
+ return rewardPercent === undefined || rewardPercent === 0
+ ? { basePercent }
+ : { basePercent, rewardPercent }
+ }
+
+ async function prepare(request: ChainActionRequest, account: string): Promise {
+ if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.')
+ const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn)
+ if (request.kind !== 'supply' || token === undefined || !SUPPORTED.has(token.symbol)) {
+ throw new ChainActionRefused(
+ `Jupiter Lend here takes USDC or USDT deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Jupiter Lend".`
+ )
+ }
+ const amount = toBaseUnits(request.amount, token.decimals)
+
+ const apy = await readApy(token.id)
+ const { status, body } = await venueFetch(`${JUPITER_API}/lend/v1/earn/deposit`, {
+ ...shared,
+ method: 'POST',
+ body: { asset: token.id, amount, signer: account },
+ })
+ const deposit = Deposit.safeParse(body)
+ if (status !== 200 || !deposit.success) {
+ throw new ChainActionRefused(
+ 'Jupiter Lend could not build this deposit right now. Nothing was signed or sent, so try again in a moment.'
+ )
+ }
+ assertDepositAmount(deposit.data.transaction, JUPITER_LEND_PROGRAM, amount)
+ const transaction = withReceiptAccount(deposit.data.transaction, account)
+
+ return {
+ venueId: 'jupiter-lend',
+ venueName: 'Jupiter Lend Earn',
+ kind: 'supply',
+ summary: `Deposit ${request.amount} ${token.symbol} into Jupiter Lend Earn, where it is lent to borrowers.`,
+ pay: { amount: request.amount, symbol: token.symbol },
+ apy,
+ notes: [
+ `Jupiter labels this API beta, and ${apy === undefined ? 'I couldn’t read the current rate just now. The rate' : 'the rate shown is today’s. It'} changes with borrowing demand and is not a promise.`,
+ 'Your deposit is lent out through smart contracts. Those can fail, and vault collateral is not fully isolated from other markets on Jupiter Lend, so you could lose money.',
+ 'Withdrawals depend on available liquidity and can be delayed when borrowing is high. A first deposit also pays about 0.002 SOL to open the token account holding your position.',
+ ],
+ signing: { family: 'solana', transaction },
+ }
+ }
+
+ return {
+ id: 'jupiter-lend',
+ name: 'Jupiter Lend Earn',
+ kinds: ['supply'],
+ prepare,
+ assertSafe: solanaAssertSafe(JUPITER_LEND_POLICY),
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/jupiter-swap.ts b/apps/dapp/lib/chains/solana/jupiter-swap.ts
new file mode 100644
index 00000000..a7dc9bd4
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/jupiter-swap.ts
@@ -0,0 +1,299 @@
+import { isAddress } from '@solana/kit'
+import { z } from 'zod'
+
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainToken,
+ type ChainVenue,
+ type PreparedAction,
+ type SubmitInput,
+ type SubmitResult,
+} from '../types'
+import { assertWrapAtMost, solanaAssertSafe } from './assert-safe'
+import { JUPITER_API, jupiterHeaders, venueFetch } from './http'
+import { JUPITER_SWAP_POLICY } from './programs'
+import { explorerUrl, feePayerSignature } from './submit'
+import { fromBaseUnits, SOLANA_TOKENS } from './tokens'
+
+const MAX_PRICE_IMPACT_PERCENT = 3
+const MAX_VALUE_LOSS = 0.05
+const SLIPPAGE_BPS = 100
+const WRAPPED_SOL = 'So11111111111111111111111111111111111111112'
+/** Jupiter's lowest referral fee on `/order`. Only charged when an account is configured. */
+const REFERRAL_FEE_BPS = 50
+/**
+ * JupiterZ makes a market maker the fee payer, which `assertSafe` refuses. DFlow
+ * and OKX land through programs not confirmable from their docs. See programs.ts.
+ */
+const EXCLUDED_ROUTERS = 'jupiterz,dflow,okx'
+/** `/execute` waits for the transaction to land, so it gets longer than other calls. */
+const EXECUTE_TIMEOUT_MS = 70_000
+
+const NEED_SOL_FOR_FEES =
+ 'This wallet needs at least 0.01 SOL to pay its own network fees before I can swap. Add a little SOL and try again.'
+
+const Order = z.object({
+ transaction: z.string().nullable().optional(),
+ requestId: z.string().min(1).optional(),
+ inputMint: z.string(),
+ outputMint: z.string(),
+ inAmount: z.string().regex(/^\d+$/),
+ outAmount: z.string().regex(/^\d+$/),
+ otherAmountThreshold: z.string().regex(/^\d+$/).optional(),
+ slippageBps: z.number().optional(),
+ priceImpact: z.number().optional(),
+ feeBps: z.number().optional(),
+ router: z.string().optional(),
+ gasless: z.boolean().optional(),
+ taker: z.string().nullable().optional(),
+ signatureFeePayer: z.string().nullable().optional(),
+ rentFeeLamports: z.number().optional(),
+ errorCode: z.number().optional(),
+})
+
+const Executed = z.object({
+ status: z.string(),
+ signature: z.string().optional(),
+ code: z.number().optional(),
+})
+
+const TOKEN_NOTES: Record = {
+ ONyc: 'ONyc is tokenised reinsurance, not a $1 token. Its price can move, and redemption through OnRe has windows and holding periods, so getting out may mean selling on a market.',
+ JitoSOL: 'JitoSOL is staked SOL, so its value follows the price of SOL, not the dollar.',
+}
+
+function explainOrderError(code: number | undefined, tokenIn: ChainToken): string {
+ if (code === 1) {
+ return `This wallet doesn't hold enough ${tokenIn.symbol} for this swap. Lower the amount or add funds.`
+ }
+ if (code === 2) {
+ return 'This wallet doesn’t have enough SOL for network fees. Add a little SOL and try again.'
+ }
+ if (code === 3) return 'This swap is too small to route. Try a larger amount.'
+ return 'Jupiter could not build this swap. Nothing was signed or sent. Check that you hold the token and a little SOL for fees, then try again.'
+}
+
+function explainExecuteFailure(code: number | undefined): string {
+ if (code === -1 || code === -1004 || code === -1005 || code === -2003) {
+ return 'This swap expired before it landed, so nothing moved. Get a fresh quote and sign right away.'
+ }
+ if (code === -1000 || code === -2000) {
+ return 'This swap did not land on the network, so nothing moved. Get a fresh quote and try again.'
+ }
+ if (code === -2 || code === -3 || code === -1002 || code === -1003 || code === -2002) {
+ return 'Jupiter could not read this signed transaction, so nothing moved. Sign a fresh quote with the same wallet.'
+ }
+ return 'This swap did not go through. Check your wallet’s activity, then get a fresh quote if nothing changed.'
+}
+
+function shortAmount(amount: string): string {
+ const value = Number(amount)
+ return value >= 1 ? String(Number(value.toFixed(4))) : String(Number(value.toPrecision(4)))
+}
+
+function validReferral(env: Record): string | undefined {
+ const account = env['JUPITER_REFERRAL_ACCOUNT']?.trim()
+ return account !== undefined && isAddress(account) ? account : undefined
+}
+
+export interface JupiterSwapOptions {
+ priceUsd: (token: ChainToken) => Promise
+ env?: Record
+ retryDelayMs?: number
+}
+
+/**
+ * Jupiter Swap's order-and-execute flow: `/order` returns a transaction for the
+ * wallet to sign and `/execute` lands it, so `submit` goes back to Jupiter instead
+ * of the RPC. The quote's request id travels through `PreparedAction.context`.
+ */
+export function createJupiterSwapVenue(options: JupiterSwapOptions): ChainVenue {
+ const env = options.env ?? process.env
+ const assertSafe = solanaAssertSafe(JUPITER_SWAP_POLICY)
+
+ async function prepare(request: ChainActionRequest, account: string): Promise {
+ if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.')
+ if (request.kind !== 'swap') {
+ throw new ChainActionRefused(`Jupiter swaps tokens. It can't ${request.kind} here.`)
+ }
+ const tokenIn = tokenBySymbol(SOLANA_TOKENS, request.tokenIn)
+ const tokenOut = tokenBySymbol(SOLANA_TOKENS, request.tokenOut ?? '')
+ if (tokenIn === undefined || tokenOut === undefined) {
+ throw new ChainActionRefused('I can only swap tokens I have confirmed on Solana.')
+ }
+ const amount = toBaseUnits(request.amount, tokenIn.decimals)
+ const referral = validReferral(env)
+
+ const query = new URLSearchParams({
+ inputMint: tokenIn.id,
+ outputMint: tokenOut.id,
+ amount,
+ taker: account,
+ slippageBps: String(SLIPPAGE_BPS),
+ excludeRouters: EXCLUDED_ROUTERS,
+ ...(referral !== undefined
+ ? { referralAccount: referral, referralFee: String(REFERRAL_FEE_BPS) }
+ : {}),
+ })
+ const { status, body } = await venueFetch(`${JUPITER_API}/swap/v2/order?${query}`, {
+ label: 'Jupiter',
+ headers: jupiterHeaders(env),
+ ...(options.retryDelayMs !== undefined ? { retryDelayMs: options.retryDelayMs } : {}),
+ })
+ const parsed = Order.safeParse(body)
+ if (status !== 200 || !parsed.success) {
+ throw new ChainActionRefused(
+ 'Jupiter could not build this swap. Nothing was signed or sent. Check that you hold the token and a little SOL for fees, then try again.'
+ )
+ }
+ const order = parsed.data
+
+ if (
+ order.inputMint !== tokenIn.id ||
+ order.outputMint !== tokenOut.id ||
+ order.inAmount !== amount ||
+ order.taker !== account
+ ) {
+ throw new ChainActionRefused(
+ 'Jupiter returned a quote that is not what you asked for, so I stopped here. Nothing was signed or sent.'
+ )
+ }
+ if (order.transaction === undefined || order.transaction === null || order.transaction === '') {
+ throw new ChainActionRefused(explainOrderError(order.errorCode, tokenIn))
+ }
+ if (order.gasless === true || order.signatureFeePayer !== account) {
+ throw new ChainActionRefused(NEED_SOL_FOR_FEES)
+ }
+ if (order.router !== 'metis') {
+ throw new ChainActionRefused(
+ 'Jupiter offered a route I can’t check, so I stopped here. Nothing was signed or sent. Try again in a moment.'
+ )
+ }
+ if (order.requestId === undefined || order.otherAmountThreshold === undefined) {
+ throw new ChainActionRefused(
+ 'This quote could not be checked because it was missing details. Nothing was signed or sent, so try again in a moment.'
+ )
+ }
+ if (order.slippageBps === undefined || order.slippageBps > SLIPPAGE_BPS) {
+ throw new ChainActionRefused(
+ 'This quote allows more slippage than the 1% I accept, so I stopped here. Nothing was signed or sent.'
+ )
+ }
+ if (order.priceImpact === undefined) {
+ throw new ChainActionRefused(
+ 'Jupiter did not report this swap’s price impact, so I stopped here. Nothing was signed or sent.'
+ )
+ }
+ if (Math.abs(order.priceImpact) > MAX_PRICE_IMPACT_PERCENT) {
+ throw new ChainActionRefused(
+ `This swap has a price impact of about ${Math.abs(order.priceImpact).toFixed(1)}%, above the ${MAX_PRICE_IMPACT_PERCENT}% I accept. There isn't enough liquidity for this size, so try a smaller amount.`
+ )
+ }
+
+ const minimum = fromBaseUnits(order.otherAmountThreshold, tokenOut.decimals)
+ const [priceIn, priceOut] = await Promise.all([
+ options.priceUsd(tokenIn),
+ options.priceUsd(tokenOut),
+ ])
+ if (priceIn === undefined || priceOut === undefined) {
+ throw new ChainActionRefused(
+ `I can't value both sides of this swap right now, so I can't check it's fair. Nothing was signed or sent, so try again in a moment.`
+ )
+ }
+ const valueIn = Number(request.amount) * priceIn
+ const valueOut = Number(minimum) * priceOut
+ if (valueOut < valueIn * (1 - MAX_VALUE_LOSS)) {
+ throw new ChainActionRefused(
+ `At worst this swap returns about $${valueOut.toFixed(2)} of ${tokenOut.symbol} for $${valueIn.toFixed(2)} of ${tokenIn.symbol}, more than ${MAX_VALUE_LOSS * 100}% less than it is worth. That usually means thin liquidity, so I stopped here. Try a smaller amount or another token.`
+ )
+ }
+
+ if (tokenIn.id === WRAPPED_SOL) assertWrapAtMost(order.transaction, BigInt(amount))
+
+ const received = fromBaseUnits(order.outAmount, tokenOut.decimals)
+ const notes = [
+ `The price can move before this lands. You get at least ${minimum} ${tokenOut.symbol} or the swap fails, and network fees still apply.`,
+ ]
+ const tokenNote = TOKEN_NOTES[tokenOut.symbol]
+ if (tokenNote !== undefined) notes.push(tokenNote)
+ if (referral !== undefined) {
+ notes.push(
+ `This swap includes a ${REFERRAL_FEE_BPS / 100}% fee to Intent, already taken from the amount shown.`
+ )
+ } else if ((order.rentFeeLamports ?? 0) > 0) {
+ notes.push(
+ `Your wallet also pays about ${fromBaseUnits(String(order.rentFeeLamports), 9)} SOL to open a token account for ${tokenOut.symbol}.`
+ )
+ }
+
+ return {
+ venueId: 'jupiter',
+ venueName: 'Jupiter',
+ kind: 'swap',
+ summary: `Swap ${request.amount} ${tokenIn.symbol} for about ${shortAmount(received)} ${tokenOut.symbol} through Jupiter.`,
+ pay: { amount: request.amount, symbol: tokenIn.symbol },
+ receive: { amount: received, symbol: tokenOut.symbol, approximate: true },
+ notes,
+ signing: { family: 'solana', transaction: order.transaction },
+ context: { requestId: order.requestId },
+ }
+ }
+
+ async function submit(input: SubmitInput): Promise {
+ if (input.signed.family !== 'solana') {
+ return { ok: false, error: 'That is not a Solana transaction.' }
+ }
+ const requestId = input.context?.['requestId']
+ if (requestId === undefined || requestId === '') {
+ return {
+ ok: false,
+ error:
+ 'This swap lost track of its quote, so nothing was sent. Get a fresh quote and sign it.',
+ }
+ }
+ const { signedTransaction } = input.signed
+ if (feePayerSignature(signedTransaction) === undefined) {
+ return {
+ ok: false,
+ error: 'Your wallet returned this unsigned, so nothing was sent. Sign it again.',
+ }
+ }
+
+ const response = await fetch(`${JUPITER_API}/swap/v2/execute`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...jupiterHeaders(env) },
+ body: JSON.stringify({ signedTransaction, requestId }),
+ signal: AbortSignal.timeout(EXECUTE_TIMEOUT_MS),
+ })
+ // A server failure or timeout leaves the outcome unknown. Throwing sends it to the
+ // route, which tells the user to check their wallet before trying again.
+ if (response.status >= 500 || response.status === 429) {
+ throw new Error(`Jupiter /execute answered ${response.status}`)
+ }
+ const parsed = Executed.safeParse(await response.json().catch(() => undefined))
+ if (!response.ok || !parsed.success) {
+ return {
+ ok: false,
+ error:
+ 'Jupiter would not take this swap, so nothing was sent. Get a fresh quote and try again.',
+ }
+ }
+ const { status, signature, code } = parsed.data
+ if (status === 'Success' && signature !== undefined) {
+ return { ok: true, hash: signature, explorerUrl: explorerUrl(signature) }
+ }
+ return { ok: false, error: explainExecuteFailure(code) }
+ }
+
+ return {
+ id: 'jupiter',
+ name: 'Jupiter',
+ kinds: ['swap'],
+ prepare,
+ assertSafe,
+ submit,
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/kamino.ts b/apps/dapp/lib/chains/solana/kamino.ts
new file mode 100644
index 00000000..825ff531
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/kamino.ts
@@ -0,0 +1,184 @@
+import { isAddress } from '@solana/kit'
+import { z } from 'zod'
+
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+} from '../types'
+import { assertDepositAmount, solanaAssertSafe } from './assert-safe'
+import { venueFetch } from './http'
+import { KAMINO_POLICY, KVAULT_PROGRAM } from './programs'
+import { fromBaseUnits, SOLANA_TOKENS } from './tokens'
+
+const KAMINO_API = 'https://api.kamino.finance'
+const VAULT_CACHE_MS = 5 * 60_000
+const USDC = 'USDC'
+
+const Vault = z.object({
+ address: z.string(),
+ programId: z.string().optional(),
+ state: z.object({
+ name: z.string().optional(),
+ tokenMint: z.string(),
+ prevAum: z.string(),
+ minDepositAmount: z.string().optional(),
+ }),
+})
+type Vault = z.infer
+
+const Metrics = z.object({
+ apy: z.string(),
+ apyFarmRewards: z.string().optional(),
+ apyIncentives: z.string().optional(),
+ apyReservesIncentives: z.string().optional(),
+})
+
+const Deposit = z.object({ transaction: z.string().min(1) })
+
+/**
+ * The USDC vault to use: the one holding the most, by assets under management.
+ *
+ * Deliberately not the highest rate. Rates spike and fall within days, one vault
+ * went from about 5% to under 3% in a month, and a rate is what a thin or
+ * risk-taking vault can offer most easily. How much depositors have put in moves
+ * slowly and is costly to fake. It is not a safety rating, so the note and summary
+ * name the vault for the user to judge.
+ *
+ * Only vaults on Kamino's mainnet kVault program count, so a staging deployment
+ * in the same list can't be chosen.
+ */
+export function chooseUsdcVault(vaults: readonly Vault[], usdcMint: string): Vault | undefined {
+ let best: Vault | undefined
+ let bestAum = 0
+ for (const vault of vaults) {
+ if (vault.state.tokenMint !== usdcMint) continue
+ if (vault.programId !== undefined && vault.programId !== KVAULT_PROGRAM) continue
+ const aum = Number(vault.state.prevAum)
+ if (Number.isFinite(aum) && aum > bestAum) {
+ best = vault
+ bestAum = aum
+ }
+ }
+ return best
+}
+
+/** A vault's name is set on chain by whoever runs it, so only plain characters get shown. */
+function displayName(vault: Vault): string {
+ const cleaned = (vault.state.name ?? '')
+ .replace(/<[^>]*>/g, '')
+ .replace(/[^\p{L}\p{N} ._+-]/gu, '')
+ .trim()
+ .slice(0, 40)
+ return cleaned === '' ? `vault ${vault.address.slice(0, 6)}` : cleaned
+}
+
+function percent(fraction: string | undefined): number {
+ const value = Number(fraction)
+ return Number.isFinite(value) && value > 0 ? value * 100 : 0
+}
+
+export interface KaminoOptions {
+ cacheMs?: number
+}
+
+export function createKaminoVenue(options: KaminoOptions = {}): ChainVenue {
+ const cacheMs = options.cacheMs ?? VAULT_CACHE_MS
+ let cached: { at: number; vaults: Vault[] } | undefined
+
+ async function readVaults(): Promise {
+ if (cached !== undefined && Date.now() - cached.at < cacheMs) return cached.vaults
+ const { status, body } = await venueFetch(`${KAMINO_API}/kvaults/vaults`, { label: 'Kamino' })
+ const vaults = z.array(z.unknown()).safeParse(body)
+ if (status !== 200 || !vaults.success) {
+ throw new ChainActionRefused(
+ 'Kamino’s vault list is unavailable right now. Nothing was signed or sent, so try again in a moment.'
+ )
+ }
+ const parsed = vaults.data.flatMap((entry) => {
+ const vault = Vault.safeParse(entry)
+ return vault.success ? [vault.data] : []
+ })
+ cached = { at: Date.now(), vaults: parsed }
+ return parsed
+ }
+
+ async function readApy(address: string): Promise {
+ const { status, body } = await venueFetch(`${KAMINO_API}/kvaults/vaults/${address}/metrics`, {
+ label: 'Kamino',
+ }).catch(() => ({ status: 0, body: undefined }))
+ const metrics = Metrics.safeParse(body)
+ if (status !== 200 || !metrics.success) return undefined
+ const { apy, apyFarmRewards, apyIncentives, apyReservesIncentives } = metrics.data
+ const basePercent = percent(apy)
+ if (basePercent === 0) return undefined
+ const rewardPercent =
+ percent(apyFarmRewards) + percent(apyIncentives) + percent(apyReservesIncentives)
+ return rewardPercent === 0 ? { basePercent } : { basePercent, rewardPercent }
+ }
+
+ async function prepare(request: ChainActionRequest, account: string): Promise {
+ if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.')
+ const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn)
+ if (request.kind !== 'supply' || token === undefined || token.symbol !== USDC) {
+ throw new ChainActionRefused(
+ `Kamino here takes USDC deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Kamino".`
+ )
+ }
+ const amount = toBaseUnits(request.amount, token.decimals)
+
+ const vault = chooseUsdcVault(await readVaults(), token.id)
+ if (vault === undefined) {
+ throw new ChainActionRefused(
+ 'I couldn’t find a Kamino USDC vault right now. Nothing was signed or sent, so try again in a moment.'
+ )
+ }
+ const minimum = vault.state.minDepositAmount
+ if (minimum !== undefined && /^\d+$/.test(minimum) && BigInt(amount) < BigInt(minimum)) {
+ throw new ChainActionRefused(
+ `This vault takes at least ${fromBaseUnits(minimum, token.decimals)} USDC. Try a larger amount.`
+ )
+ }
+
+ const apy = await readApy(vault.address)
+ const { status, body } = await venueFetch(`${KAMINO_API}/ktx/kvault/deposit`, {
+ label: 'Kamino',
+ method: 'POST',
+ body: { wallet: account, kvault: vault.address, amount: request.amount },
+ })
+ const deposit = Deposit.safeParse(body)
+ if (status !== 200 || !deposit.success) {
+ throw new ChainActionRefused(
+ 'Kamino could not build this deposit right now. Nothing was signed or sent. Check your USDC and SOL balances, then try again.'
+ )
+ }
+ assertDepositAmount(deposit.data.transaction, KVAULT_PROGRAM, amount)
+
+ const name = displayName(vault)
+ return {
+ venueId: 'kamino',
+ venueName: 'Kamino Earn',
+ kind: 'supply',
+ summary: `Deposit ${request.amount} USDC into Kamino's "${name}" vault, currently its largest USDC vault by deposits.`,
+ pay: { amount: request.amount, symbol: USDC },
+ apy,
+ notes: [
+ `"${name}" is run by a curator who decides where its funds are lent. Being the largest vault is not a safety rating, and you could lose money if a market or smart contract fails.`,
+ `${apy === undefined ? 'I couldn’t read the current rate just now, and the rate' : 'The rate'} changes often and is not a promise.`,
+ 'Withdrawals depend on available liquidity and can be delayed when markets are stressed.',
+ ],
+ signing: { family: 'solana', transaction: deposit.data.transaction },
+ }
+ }
+
+ return {
+ id: 'kamino',
+ name: 'Kamino Earn',
+ kinds: ['supply'],
+ prepare,
+ assertSafe: solanaAssertSafe(KAMINO_POLICY),
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/lulo.ts b/apps/dapp/lib/chains/solana/lulo.ts
new file mode 100644
index 00000000..e15c42d0
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/lulo.ts
@@ -0,0 +1,108 @@
+import { isAddress } from '@solana/kit'
+import { z } from 'zod'
+
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+} from '../types'
+import { assertDepositAmount, solanaAssertSafe } from './assert-safe'
+import { venueFetch } from './http'
+import { LULO_POLICY, LULO_PROGRAM } from './programs'
+import { SOLANA_TOKENS } from './tokens'
+
+const LULO_API = 'https://api.lulo.fi'
+
+const Deposit = z.object({
+ transaction: z.string().min(1),
+ simulateError: z.string().nullable().optional(),
+})
+
+const Rates = z.object({ protected: z.object({ CURRENT: z.number().finite().positive() }) })
+
+export interface LuloOptions {
+ apiKey: string
+}
+
+/**
+ * Lulo Protected. Off unless `LULO_API_KEY` is set, because Lulo's docs require one.
+ *
+ * `protectedAmount` is in display units. Lulo's docs show `1000000` beside a USDC
+ * mint, but a live build for that value deposited 1,000,000 USDC, so the amount in
+ * every returned transaction is checked against the request.
+ */
+export function createLuloVenue(options: LuloOptions): ChainVenue {
+ const headers = { 'x-api-key': options.apiKey }
+
+ async function readApy(): Promise {
+ const { status, body } = await venueFetch(`${LULO_API}/v1/rates.getRates`, {
+ label: 'Lulo',
+ headers,
+ }).catch(() => ({ status: 0, body: undefined }))
+ const rates = Rates.safeParse(body)
+ return status === 200 && rates.success
+ ? { basePercent: rates.data.protected.CURRENT }
+ : undefined
+ }
+
+ async function prepare(request: ChainActionRequest, account: string): Promise {
+ if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.')
+ const token = tokenBySymbol(SOLANA_TOKENS, request.tokenIn)
+ if (request.kind !== 'supply' || token === undefined || token.symbol !== 'USDC') {
+ throw new ChainActionRefused(
+ `Lulo here takes USDC deposits only, not ${request.tokenIn}. Try "supply 10 USDC on Lulo".`
+ )
+ }
+ const amount = toBaseUnits(request.amount, token.decimals)
+
+ const apy = await readApy()
+ const { status, body } = await venueFetch(`${LULO_API}/v1/generate.transactions.deposit`, {
+ label: 'Lulo',
+ method: 'POST',
+ headers,
+ body: {
+ owner: account,
+ mintAddress: token.id,
+ protectedAmount: Number(request.amount),
+ },
+ })
+ const deposit = Deposit.safeParse(body)
+ if (status !== 200 || !deposit.success) {
+ throw new ChainActionRefused(
+ 'Lulo could not build this deposit right now. Nothing was signed or sent, so try again in a moment.'
+ )
+ }
+ if (deposit.data.simulateError !== undefined && deposit.data.simulateError !== null) {
+ throw new ChainActionRefused(
+ 'This deposit would fail. Check your USDC balance, and that you have a little SOL for fees, then try again.'
+ )
+ }
+ assertDepositAmount(deposit.data.transaction, LULO_PROGRAM, amount)
+
+ return {
+ venueId: 'lulo',
+ venueName: 'Lulo Protected',
+ kind: 'supply',
+ summary: `Deposit ${request.amount} USDC into Lulo Protected, which lends it across Solana lending protocols.`,
+ pay: { amount: request.amount, symbol: 'USDC' },
+ apy,
+ notes: [
+ 'The cover here is first-loss funding from other depositors, not insurance. It has a limit, and a large enough failure could still lose you money.',
+ `${apy === undefined ? 'I couldn’t read the current rate just now, and the rate' : 'The rate'} changes often and is not a promise.`,
+ 'Withdrawals depend on the liquidity of the protocols Lulo lends to and can be delayed.',
+ ],
+ signing: { family: 'solana', transaction: deposit.data.transaction },
+ }
+ }
+
+ return {
+ id: 'lulo',
+ name: 'Lulo Protected',
+ kinds: ['supply'],
+ prepare,
+ assertSafe: solanaAssertSafe(LULO_POLICY),
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts
new file mode 100644
index 00000000..5b87810b
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/module.ts
@@ -0,0 +1,31 @@
+import type { ChainModule, ChainVenue } from '../types'
+import { createJupiterLendVenue } from './jupiter-lend'
+import { createJupiterSwapVenue } from './jupiter-swap'
+import { createKaminoVenue } from './kamino'
+import { createLuloVenue } from './lulo'
+import { createPriceSource } from './price'
+import { createSolanaSubmit } from './submit'
+import { SOLANA_TOKENS } from './tokens'
+
+const priceUsd = createPriceSource()
+
+export function buildSolanaVenues(
+ env: Record = process.env
+): ChainVenue[] {
+ const luloKey = env['LULO_API_KEY']?.trim()
+ return [
+ createJupiterSwapVenue({ priceUsd, env }),
+ createJupiterLendVenue({ env }),
+ createKaminoVenue(),
+ ...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []),
+ ]
+}
+
+export const solanaModule: ChainModule = {
+ chain: 'solana',
+ tokens: SOLANA_TOKENS,
+ venues: buildSolanaVenues(),
+ defaultVenue: { swap: 'jupiter', supply: 'jupiter-lend' },
+ priceUsd,
+ submit: createSolanaSubmit(),
+}
diff --git a/apps/dapp/lib/chains/solana/price.ts b/apps/dapp/lib/chains/solana/price.ts
new file mode 100644
index 00000000..79bd2681
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/price.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod'
+
+import type { ChainToken } from '../types'
+import { JUPITER_API, jupiterHeaders, venueFetch } from './http'
+import { SOLANA_TOKENS } from './tokens'
+
+const CACHE_MS = 20_000
+
+const Entry = z.object({ usdPrice: z.number().finite().positive() })
+
+export interface PriceSourceOptions {
+ env?: Record
+ retryDelayMs?: number
+}
+
+/**
+ * Live dollar prices from Jupiter's Price API, which omits a token it has no
+ * reliable price for. A stablecoin gets what the source says, not an assumed $1,
+ * and anything missing stays missing so the trade is refused.
+ *
+ * All tokens are read in one request and briefly kept, since a keyless caller may
+ * make one request every two seconds and building an action takes several.
+ */
+export function createPriceSource(options: PriceSourceOptions = {}) {
+ const env = options.env ?? process.env
+ let cached: { at: number; table: Promise> } | undefined
+
+ async function readTable(): Promise> {
+ const ids = SOLANA_TOKENS.map((t) => t.id).join(',')
+ const { status, body } = await venueFetch(`${JUPITER_API}/price/v3?ids=${ids}`, {
+ label: 'Jupiter',
+ headers: jupiterHeaders(env),
+ ...(options.retryDelayMs !== undefined ? { retryDelayMs: options.retryDelayMs } : {}),
+ })
+ if (status !== 200 || typeof body !== 'object' || body === null) return new Map()
+ const table = new Map()
+ for (const [mint, entry] of Object.entries(body)) {
+ const parsed = Entry.safeParse(entry)
+ if (parsed.success) table.set(mint, parsed.data.usdPrice)
+ }
+ return table
+ }
+
+ return async (token: ChainToken): Promise => {
+ if (cached === undefined || Date.now() - cached.at > CACHE_MS) {
+ const table = readTable()
+ cached = { at: Date.now(), table }
+ // A failed read is not worth keeping: the next trade should ask again.
+ void table.then(
+ (read) => {
+ if (read.size === 0) cached = undefined
+ },
+ () => {
+ cached = undefined
+ }
+ )
+ }
+ try {
+ return (await cached.table).get(token.id)
+ } catch {
+ return undefined
+ }
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/programs.ts b/apps/dapp/lib/chains/solana/programs.ts
new file mode 100644
index 00000000..ef88dd69
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/programs.ts
@@ -0,0 +1,63 @@
+import type { SolanaVenuePolicy } from './assert-safe'
+
+/**
+ * Program ids each venue may invoke at the top level of a transaction.
+ *
+ * Confirmed against the venue's own docs and then seen in a live, unsigned
+ * transaction on 3 November 2026. An id that only appears in what a venue's API
+ * returned is not confirmed by that alone, since the API is what is being checked.
+ * That is why DFlow and OKX, which Jupiter also routes through, are not here.
+ */
+
+/** https://developers.jup.ag/docs/llms-full.txt names it as the Jupiter v6 aggregator. */
+export const JUPITER_AGGREGATOR_PROGRAM = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'
+
+/** https://developers.jup.ag/docs/lend/architecture.md, "Lending (Earn)". */
+export const JUPITER_LEND_PROGRAM = 'jup3YeL8QhtSx1e253b2FDvsMNC87fDrgQZivbrndc9'
+
+/** https://kamino.com/docs/build/resources/program-addresses.md, "Kvault" on mainnet. */
+export const KVAULT_PROGRAM = 'KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd'
+
+/** https://kamino.com/docs/build/resources/program-addresses.md, "Kfarms" on mainnet. */
+export const KFARMS_PROGRAM = 'FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr'
+
+/** https://lulo.fi/docs/integration-guide, "Program ID". */
+export const LULO_PROGRAM = 'FL3X2pRsQ9zHENpZSKDRREtccwJuei8yg9fwDu9UN69Q'
+
+/** Anchor's discriminator for `deposit`, which Jupiter Lend Earn and Kamino kVaults share. */
+export const DEPOSIT_DISCRIMINATOR = 'f223c68952e1f2b6'
+
+const LULO_DEPOSIT_PROTECTED = 'b7d84be2fe90d407'
+const KFARMS_INIT_USER = '6f11b9fa3c7a26fe'
+const KFARMS_STAKE = 'ceb0ca12c8d1b36c'
+
+/**
+ * A swap route's instructions vary with the route, so Jupiter's are not narrowed
+ * to one discriminator. Only this venue may wrap SOL.
+ */
+export const JUPITER_SWAP_POLICY: SolanaVenuePolicy = {
+ venueName: 'Jupiter',
+ programs: { [JUPITER_AGGREGATOR_PROGRAM]: {} },
+ mayWrapSol: true,
+}
+
+export const JUPITER_LEND_POLICY: SolanaVenuePolicy = {
+ venueName: 'Jupiter Lend',
+ programs: { [JUPITER_LEND_PROGRAM]: { discriminators: [DEPOSIT_DISCRIMINATOR] } },
+ mayWrapSol: false,
+}
+
+export const KAMINO_POLICY: SolanaVenuePolicy = {
+ venueName: 'Kamino',
+ programs: {
+ [KVAULT_PROGRAM]: { discriminators: [DEPOSIT_DISCRIMINATOR] },
+ [KFARMS_PROGRAM]: { discriminators: [KFARMS_INIT_USER, KFARMS_STAKE] },
+ },
+ mayWrapSol: false,
+}
+
+export const LULO_POLICY: SolanaVenuePolicy = {
+ venueName: 'Lulo',
+ programs: { [LULO_PROGRAM]: { discriminators: [LULO_DEPOSIT_PROTECTED] } },
+ mayWrapSol: false,
+}
diff --git a/apps/dapp/lib/chains/solana/sign.ts b/apps/dapp/lib/chains/solana/sign.ts
new file mode 100644
index 00000000..d5e7ed67
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/sign.ts
@@ -0,0 +1,32 @@
+import type { Wallet, WalletAccount } from '@wallet-standard/base'
+
+import { base64FromBytes, bytesFromBase64 } from '../base64'
+import type { SignedPayload, SigningPayload } from '../types'
+
+interface SolanaSignTransaction {
+ 'solana:signTransaction'?: {
+ signTransaction: (input: {
+ account: WalletAccount
+ transaction: Uint8Array
+ chain: string
+ }) => Promise
+ }
+}
+
+/** The Wallet Standard's Solana signing feature. The server checks and sends the result. */
+export async function signSolana(
+ wallet: Wallet,
+ account: WalletAccount,
+ payload: SigningPayload
+): Promise {
+ if (payload.family !== 'solana') throw new Error('That is not a Solana transaction.')
+ const feature = (wallet.features as SolanaSignTransaction)['solana:signTransaction']
+ if (feature === undefined) throw new Error(`${wallet.name} cannot sign Solana transactions here.`)
+ const [output] = await feature.signTransaction({
+ account,
+ transaction: bytesFromBase64(payload.transaction),
+ chain: 'solana:mainnet',
+ })
+ if (output === undefined) throw new Error('The wallet returned nothing to send.')
+ return { family: 'solana', signedTransaction: base64FromBytes(output.signedTransaction) }
+}
diff --git a/apps/dapp/lib/chains/solana/submit.ts b/apps/dapp/lib/chains/solana/submit.ts
new file mode 100644
index 00000000..aa2fa05c
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/submit.ts
@@ -0,0 +1,136 @@
+import { getBase58Decoder } from '@solana/kit'
+import { z } from 'zod'
+
+import type { SubmitInput, SubmitResult } from '../types'
+import { decodeSolanaTransaction } from './assert-safe'
+
+const DEFAULT_RPC_URL = 'https://solana-rpc.publicnode.com'
+const RPC_TIMEOUT_MS = 15_000
+/** About 60 seconds, which is roughly how long a blockhash lasts. */
+const POLL_MS = 2_000
+const MAX_POLLS = 30
+
+export function explorerUrl(hash: string): string {
+ return `https://solscan.io/tx/${hash}`
+}
+
+/** The fee payer's signature as the network names it, or nothing if the wallet left it empty. */
+export function feePayerSignature(signedTransaction: string): string | undefined {
+ const signature = decodeSolanaTransaction(signedTransaction).feePayerSignature
+ return signature === null ? undefined : getBase58Decoder().decode(signature)
+}
+
+const RpcError = z.object({
+ code: z.number().optional(),
+ message: z.string().optional(),
+ data: z
+ .object({ err: z.unknown().optional(), logs: z.array(z.string()).nullable().optional() })
+ .optional(),
+})
+
+const RpcReply = z.object({ result: z.unknown().optional(), error: RpcError.optional() })
+
+const Statuses = z.object({
+ value: z.array(
+ z.object({ confirmationStatus: z.string().nullable().optional(), err: z.unknown() }).nullable()
+ ),
+})
+
+const BlockhashValid = z.object({ value: z.boolean() })
+
+const EXPIRED =
+ 'This transaction expired before it reached the network, so nothing moved. Build it again and sign right away.'
+
+/** What the network said, in a sentence. Raw logs stay out of what the user reads. */
+function explainSendError(error: z.infer): string {
+ const err = error.data?.err
+ const logs = (error.data?.logs ?? []).join(' ').toLowerCase()
+ const message = (error.message ?? '').toLowerCase()
+ if (err === 'BlockhashNotFound' || message.includes('blockhash not found')) return EXPIRED
+ if (err === 'AccountNotFound' || err === 'InsufficientFundsForFee') {
+ return 'This wallet doesn’t have enough SOL to pay the network fee, so nothing was sent. Add a little SOL and try again.'
+ }
+ if (logs.includes('insufficient funds') || logs.includes('insufficient lamports')) {
+ return 'This wallet’s balance is too low for this transaction, so nothing was sent. Check your balance, including a little SOL for fees, and try again.'
+ }
+ if (error.code === -32002 || message.includes('simulation')) {
+ return 'The network’s simulation of this transaction failed, so nothing was sent. Check your balance and build it again, since prices may have moved.'
+ }
+ return 'The network would not accept this transaction, so nothing was sent. Build it again and retry in a moment.'
+}
+
+export interface SolanaSubmitOptions {
+ env?: Record
+ pollMs?: number
+ maxPolls?: number
+}
+
+/**
+ * Sends signed bytes and waits, within a bound, for the network to confirm them.
+ * A network failure while sending throws, because the outcome is then unknown and
+ * the route tells the user to check their wallet before trying again.
+ */
+export function createSolanaSubmit(options: SolanaSubmitOptions = {}) {
+ const env = options.env ?? process.env
+ const pollMs = options.pollMs ?? POLL_MS
+ const maxPolls = options.maxPolls ?? MAX_POLLS
+
+ async function rpc(method: string, params: unknown[]): Promise> {
+ const url = env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
+ signal: AbortSignal.timeout(RPC_TIMEOUT_MS),
+ })
+ return RpcReply.parse(await response.json())
+ }
+
+ return async (input: SubmitInput): Promise => {
+ if (input.signed.family !== 'solana') {
+ return { ok: false, error: 'That is not a Solana transaction.' }
+ }
+ const { signedTransaction } = input.signed
+ const hash = feePayerSignature(signedTransaction)
+ if (hash === undefined) {
+ return {
+ ok: false,
+ error: 'Your wallet returned this unsigned, so nothing was sent. Sign it again.',
+ }
+ }
+
+ const sent = await rpc('sendTransaction', [
+ signedTransaction,
+ { encoding: 'base64', preflightCommitment: 'confirmed' },
+ ])
+ if (sent.error !== undefined) return { ok: false, error: explainSendError(sent.error) }
+
+ for (let poll = 0; poll < maxPolls; poll += 1) {
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
+ const reply = await rpc('getSignatureStatuses', [[hash]]).catch(() => undefined)
+ const statuses = Statuses.safeParse(reply?.result)
+ const status = statuses.success ? statuses.data.value[0] : undefined
+ if (status === undefined || status === null) continue
+ if (status.err !== null && status.err !== undefined) {
+ return {
+ ok: false,
+ error: `This transaction landed but failed, so your tokens did not move. Network fees may still apply. Details: ${explorerUrl(hash)}`,
+ }
+ }
+ if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') {
+ return { ok: true, hash, explorerUrl: explorerUrl(hash) }
+ }
+ }
+
+ const { blockhash } = decodeSolanaTransaction(signedTransaction)
+ const validity = await rpc('isBlockhashValid', [blockhash, { commitment: 'confirmed' }]).catch(
+ () => undefined
+ )
+ const valid = BlockhashValid.safeParse(validity?.result)
+ if (valid.success && !valid.data.value) return { ok: false, error: EXPIRED }
+ return {
+ ok: false,
+ error: `This was sent, but not confirmed yet. Check ${explorerUrl(hash)} before trying again, in case it goes through.`,
+ }
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/tokens.ts b/apps/dapp/lib/chains/solana/tokens.ts
new file mode 100644
index 00000000..9e1724c8
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/tokens.ts
@@ -0,0 +1,53 @@
+import type { ChainToken } from '../types'
+
+/**
+ * Every token this chain's venues may touch. A symbol resolves here or nowhere.
+ *
+ * Mints and decimals checked on 3 November 2026, each against its issuer's own
+ * page and against the mint account on chain:
+ * - USDC: https://developers.circle.com/stablecoins/usdc-contract-addresses
+ * - USDT: https://tether.to/en/supported-protocols
+ * - JitoSOL: https://go.jito.network/docs/jitosol/jitosol-liquid-staking/for-developers/staking-integration/
+ * - ONyc: https://docs.onre.finance/technical-resources/token-configuration-and-reference
+ * - SOL: the wrapped SOL mint, the Token Program's native mint.
+ */
+export const SOLANA_TOKENS: readonly ChainToken[] = [
+ {
+ symbol: 'SOL',
+ id: 'So11111111111111111111111111111111111111112',
+ decimals: 9,
+ name: 'Solana',
+ },
+ {
+ symbol: 'USDC',
+ id: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
+ decimals: 6,
+ name: 'USD Coin',
+ },
+ {
+ symbol: 'USDT',
+ id: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
+ decimals: 6,
+ name: 'Tether USD',
+ },
+ {
+ symbol: 'JitoSOL',
+ id: 'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn',
+ decimals: 9,
+ name: 'Jito Staked SOL',
+ },
+ {
+ symbol: 'ONyc',
+ id: '5Y8NV33Vv7WbnLfq3zBcKSdYPrk7g2KoiQoe7M2tcxp5',
+ decimals: 9,
+ name: 'OnRe Tokenized Reinsurance',
+ },
+]
+
+/** Display units for a base-unit integer, without floating point. */
+export function fromBaseUnits(digits: string, decimals: number): string {
+ const padded = digits.padStart(decimals + 1, '0')
+ const whole = padded.slice(0, padded.length - decimals)
+ const fraction = padded.slice(padded.length - decimals).replace(/0+$/, '')
+ return fraction === '' ? whole : `${whole}.${fraction}`
+}
diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts
new file mode 100644
index 00000000..75d5ce00
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/venues.ts
@@ -0,0 +1,147 @@
+import type { Venue } from '@intent/types'
+
+/**
+ * The Solana apps the Apps screen shows, from the ranked table in the integration
+ * report. `executes` means the app builds, checks, signs and submits here with no
+ * extra configuration. Everything else is a listing and claims nothing. Client-safe.
+ */
+export const solanaVenues: Venue[] = [
+ {
+ id: 'jupiter',
+ family: 'solana',
+ name: 'Jupiter',
+ category: 'aggregator',
+ chains: ['Solana'],
+ bestFor: 'Swapping between major Solana tokens at a routed price',
+ url: 'https://jup.ag',
+ integration: 'executes',
+ capability:
+ 'Swaps USDC, USDT, SOL, JitoSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.',
+ },
+ {
+ id: 'jupiter-lend',
+ family: 'solana',
+ name: 'Jupiter Lend Earn',
+ category: 'lending',
+ chains: ['Solana'],
+ bestFor: 'Earning a variable rate by lending USDC or USDT',
+ url: 'https://jup.ag/lend',
+ integration: 'executes',
+ capability:
+ 'Deposits USDC or USDT and shows the current rate. Jupiter labels the API beta. Withdrawing isn’t wired here yet.',
+ },
+ {
+ id: 'kamino',
+ family: 'solana',
+ name: 'Kamino Earn',
+ category: 'lending',
+ chains: ['Solana'],
+ bestFor: 'Curated USDC vaults with a variable rate',
+ url: 'https://kamino.com',
+ integration: 'executes',
+ capability:
+ 'Deposits USDC into Kamino’s largest USDC vault by deposits, and names it before you sign. Withdrawing isn’t wired here yet.',
+ },
+ {
+ id: 'onre',
+ family: 'solana',
+ name: 'OnRe (ONyc)',
+ category: 'rwa',
+ chains: ['Solana'],
+ bestFor: 'Tokenised reinsurance yield. Not a $1 token, and redemption has windows',
+ url: 'https://www.onre.finance',
+ integration: 'executes',
+ capability:
+ 'Buys or sells ONyc on the market through a Jupiter swap only. Minting and redeeming with OnRe aren’t wired here.',
+ },
+ {
+ id: 'linq-solana',
+ family: 'solana',
+ name: 'Linq (Solana USDC)',
+ category: 'offramp',
+ chains: ['Solana'],
+ bestFor: 'Naira cash-out from Solana, once Linq confirms it and a payment path exists',
+ url: 'https://switch.uselinq.xyz',
+ },
+ {
+ id: 'paycrest',
+ family: 'solana',
+ name: 'Paycrest',
+ category: 'offramp',
+ chains: ['Solana'],
+ bestFor: 'A candidate backup for naira cash-out, pending business verification',
+ url: 'https://paycrest.io',
+ },
+ {
+ id: 'lulo',
+ family: 'solana',
+ name: 'Lulo Protected',
+ category: 'savings',
+ chains: ['Solana'],
+ bestFor: 'USDC lending with depositor-funded first-loss cover, which is not insurance',
+ url: 'https://lulo.fi',
+ },
+ {
+ id: 'jupiter-trigger',
+ family: 'solana',
+ name: 'Jupiter Trigger',
+ category: 'orderbook',
+ chains: ['Solana'],
+ bestFor: 'Limit orders and recurring buys, with a $10 minimum per order or round',
+ url: 'https://jup.ag',
+ },
+ {
+ id: 'busha',
+ family: 'solana',
+ name: 'Busha',
+ category: 'offramp',
+ chains: ['Solana'],
+ bestFor: 'A candidate for regulated naira cash-out. Its docs show USDT for selling, not USDC',
+ url: 'https://busha.io',
+ },
+ {
+ id: 'sanctum',
+ family: 'solana',
+ name: 'Sanctum',
+ category: 'staking',
+ chains: ['Solana'],
+ bestFor: 'Liquid staking across many staked SOL tokens',
+ url: 'https://sanctum.so',
+ },
+ {
+ id: 'reflect',
+ family: 'solana',
+ name: 'Reflect (USDC+)',
+ category: 'savings',
+ chains: ['Solana'],
+ bestFor: 'A yield-bearing dollar token. Sources disagree on what backs the yield',
+ url: 'https://reflect.money',
+ },
+ {
+ id: 'project-0',
+ family: 'solana',
+ name: 'Project 0',
+ category: 'lending',
+ chains: ['Solana'],
+ bestFor: 'Borrowing and lending across venues, formerly marginfi',
+ url: 'https://www.0.xyz',
+ },
+ {
+ id: 'phoenix-perps',
+ family: 'solana',
+ name: 'Phoenix Perps',
+ category: 'perps',
+ chains: ['Solana'],
+ bestFor: 'Order-book perpetuals. Leverage can lose your whole position',
+ url: 'https://phoenix.trade',
+ },
+ {
+ id: 'jupiter-perps',
+ family: 'solana',
+ name: 'Jupiter Perps',
+ category: 'perps',
+ chains: ['Solana'],
+ bestFor: 'Oracle-priced perpetuals against a pool. Leverage can lose your whole position',
+ url: 'https://jup.ag/perps',
+ },
+]
diff --git a/apps/dapp/lib/chains/sui/capabilities.ts b/apps/dapp/lib/chains/sui/capabilities.ts
new file mode 100644
index 00000000..f0628937
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/capabilities.ts
@@ -0,0 +1,8 @@
+import type { ChainCapability } from '../explain'
+
+/** What actually works here, for telling someone who asked for something else. Client-safe. */
+export const suiCapabilities: ChainCapability[] = [
+ { kind: 'swap', venue: 'Cetus', symbol: 'SUI' },
+ { kind: 'supply', venue: 'NAVI', symbol: 'USDC' },
+ { kind: 'stake', venue: 'SpringSui', symbol: 'SUI' },
+]
diff --git a/apps/dapp/lib/chains/sui/captured-transactions.ts b/apps/dapp/lib/chains/sui/captured-transactions.ts
new file mode 100644
index 00000000..4e704439
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/captured-transactions.ts
@@ -0,0 +1,22 @@
+/**
+ * Unsigned transactions the real SDKs built against mainnet on 2026-11-03, for a public
+ * address that has never been signed for here. Test data only: the safety checks are
+ * proven on what the venues actually produce, and on tampered copies of it.
+ */
+export const CAPTURED_SENDER = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+
+/** 1 USDC to SUI through Bluefin. Output merges into the gas coin. */
+export const CAPTURED_CETUS_TO_SUI =
+ 'AAAbACUkZGM5MTVjNmMtNjllNy00ZGJiLTg3NDAtNmQ2ODUzYjFmYjhhAAj//////////wAIQEIPAAAAAAAACINfqkIAAAAAAAiIEwAAAAAAAAAEAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQPbJRulCajV2Hd7YziDYIIzXZPuy90JoR4ZChz/UcNSUaY6GAAAAAAAAQER2+uZH+pLLH78uktrIcvs0/lL6Z7IySBYOerwM1bTWMa18RwAAAAAAQABAAAI//////////8BAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQAAAAAAAAAAAQFHRkFORFzr3Blma25N6beaRsp7yqiUvxDsIw5kk3Y1boKTAiAAAAAAAQABAQAI//////////8BAFtgN2z1+BxVRo1x1YdKKwMgFaGavyhiQbcsuLbvHWPAE5d+PgAAAAAgMuRwLiCQMb9sV+Ih6Cd0qtII0sbiF5Gkj7SQvSnu4ogBAGNfplCBhULRgybi+SEVPzPLvXTer3La2Ixk37DQzx15zoZ+PgAAAAAgWrgO5noE2CZDGOzZA1agaoj60JxCyTlzS3+d7v2PproBANvX/GwzfvL8Q5oX89RxqeD+l0Nmyu4rquspQRzL3f982bp9PgAAAAAggaZvhuJmbbpuuQBcP8OAkUkCkTrUjHVFFB1N+zd+DlgBAOhL1IjT+5C8/J1fxxN2Y4x6NaGDws3ae5lPzJ8A5M74zJJ+PgAAAAAgyBrBooa7xpnx6K9XmX9Q5d69d3Igwg0UbUZAs6Hj4hwBAAABYs2BNF9N+SEo9IjJb4JkqnlXF1nXgte9nVzy91DmzAqFPgAAAAAgOHo8U8xpbPfdSXQ7wFnEEGqRq4kh134zfY84lTMtlgQBAEsjt4OyU5pKOkGnfxrFemSxo7wuVX6XdPRJP3DveB4Ixr99PgAAAAAg+zD3wEHFE91Bkb+QA15XsWH8LcmFbtOv+nRmeq8FQnQBADQaZkgiDp85FnXvkoWVeW0FAhDOLPCufa8JJqSurb72r3N+PgAAAAAgOz/lV9fyP1oS3TvHIipVUirru4U0mEcgeZWYod+TVGcBABvDTgZDXFCQahc8YZ8xYiHQo0LzWFZwh+J3JcYZMunMc8iBPgAAAAAgOI7/gwbTD/18lqvG7Gi0siwLxjd3JhdkVJxPDQzYfs4BACvL5IdUEaCrr/6nn/uGVQtsM5wuM2Z3/KNd/uyTNRoPFJd+PgAAAAAgXznrBoXfx6WWLuIx5zLcgpcMBet3uyU5n/f8T87FyzoBAEWupG6n91sQ8UU5tAaMQn7bXF6om2SxeBJLYdygc+XPTu59PgAAAAAgAVXtcMXFG/IFf9E5jZbibJFYhPhs6G0tlzrLwLecbjwBAPIeLNCdbw20KUOg/ouSMoDtvqedZUBmv5JPLlexeBKtFJd+PgAAAAAgBVce0UlFHhbF4glnk//TpvIJhxi+09lFrWjYErmK+/4ACEBCDwAAAAAABwMBDwAKARAAAREAARIAARMAARQAARUAARYAARcAARgAARkAAgEPAAEBGgAA/9QFivfW9tZsM1kwzO2MkeOOyUPiOSIyruJ+gSfmhOkGcm91dGVyE25ld19zd2FwX2NvbnRleHRfdjMCB9ujRnLjDLBlsfk+OrVTGHaP1v72bBWULJ98uEbi+QDnBHVzZGMEVVNEQwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAIAQAAAQEAAQIAAQMAAQQAAwEAAAABBQABBgAATnxLpDb4/Vs8a7UUiAzNEcUQnIPEW14Dc5S5QgTbu4AHYmx1ZWZpbgRzd2FwAgfRtymC5ANI0Gm7H/cB5jTBF7tfdB9E3/keRy07AUYeVQVzdHN1aQVTVFNVSQAH26NGcuMMsGWx+T46tVMYdo/W/vZsFZQsn3y4RuL5AOcEdXNkYwRVU0RDAAYCAgABBwABCAABCQABCgABCwAATnxLpDb4/Vs8a7UUiAzNEcUQnIPEW14Dc5S5QgTbu4AHYmx1ZWZpbgRzd2FwAgfRtymC5ANI0Gm7H/cB5jTBF7tfdB9E3/keRy07AUYeVQVzdHN1aQVTVFNVSQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDc3VpA1NVSQAGAgIAAQcAAQwAAQ0AAQ4AAQsAAP/UBYr31vbWbDNZMMztjJHjjslD4jkiMq7ifoEn5oTpBnJvdXRlcg9jb25maXJtX3N3YXBfdjMBBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACA3N1aQNTVUkAAQICAAMAAQIFAMb6SKdFW/9JEkMY1SlzIEQc+sM/g87qiQxIU3LekX30ARNvz8osuDp2lLWVcwVIyY8eI9cgjuON4JfvjcRFiKKwdMiBPgAAAAAgM1LKT2xZ97ufP3tEVZw5KyZcEB6hdrrCs1qtWkKEbq/G+kinRVv/SRJDGNUpcyBEHPrDP4PO6okMSFNy3pF99GQAAAAAAAAAQA0DAAAAAAAA'
+
+/** 1 USDC to USDsui. Output goes back through router::transfer_or_destroy_coin. */
+export const CAPTURED_CETUS_TO_USDSUI =
+ 'AAAYACUkODZhOTFiNGYtNDU1Mi00MTE5LTk1ODktNjk1N2Q0NGQ3NTg1AAj//////////wAIQEIPAAAAAAAACA9CDwAAAAAAAAiIEwAAAAAAAAAEAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAel1CTFAH9bpWS1Il2DYrSZM3Au0HkznszTNZvus3QTWAh8yMAAAAAABAQHxzw6BBI3xaOvrG4Aw+tJLPgtTroJ8JQU//wd5wURbb4+8GAAAAAAAAAABAAAI//////////8BAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQAAAAAAAAAAAQBbYDds9fgcVUaNcdWHSisDIBWhmr8oYkG3LLi27x1jwBOXfj4AAAAAIDLkcC4gkDG/bFfiIegndKrSCNLG4heRpI+0kL0p7uKIAQBjX6ZQgYVC0YMm4vkhFT8zy7103q9y2tiMZN+w0M8dec6Gfj4AAAAAIFq4DuZ6BNgmQxjs2QNWoGqI+tCcQsk5c0t/ne79j6a6AQDb1/xsM37y/EOaF/PUcang/pdDZsruK6rrKUEcy93/fNm6fT4AAAAAIIGmb4biZm26brkAXD/DgJFJApE61Ix1RRQdTfs3fg5YAQDoS9SI0/uQvPydX8cTdmOMejWhg8LN2nuZT8yfAOTO+MySfj4AAAAAIMgawaKGu8aZ8eivV5l/UOXevXdyIMINFG1GQLOh4+IcAQAAAWLNgTRfTfkhKPSIyW+CZKp5VxdZ14LXvZ1c8vdQ5swKhT4AAAAAIDh6PFPMaWz33Ul0O8BZxBBqkauJIdd+M32POJUzLZYEAQBLI7eDslOaSjpBp38axXpksaO8LlV+l3T0ST9w73geCMa/fT4AAAAAIPsw98BBxRPdQZG/kANeV7Fh/C3JhW7Tr/p0ZnqvBUJ0AQA0GmZIIg6fORZ175KFlXltBQIQzizwrn2vCSakrq2+9q9zfj4AAAAAIDs/5VfX8j9aEt07xyIqVVIq67uFNJhHIHmVmKHfk1RnAQAbw04GQ1xQkGoXPGGfMWIh0KNC81hWcIfidyXGGTLpzHPIgT4AAAAAIDiO/4MG0w/9fJarxuxotLIsC8Y3dyYXZFScTw0M2H7OAQAry+SHVBGgq6/+p5/7hlULbDOcLjNmd/yjXf7skzUaDxSXfj4AAAAAIF856waF38elli7iMecy3IKXDAXrd7slOZ/3/E/Oxcs6AQBFrqRup/dbEPFFObQGjEJ+21xeqJtksXgSS2HcoHPlz07ufT4AAAAAIAFV7XDFxRvyBX/ROY2W4myRWIT4bOhtLZc6y8C3nG48AQDyHizQnW8NtClDoP6LkjKA7b6nnWVAZr+STy5XsXgSrRSXfj4AAAAAIAVXHtFJRR4WxeIJZ5P/06byCYcYvtPZRa1o2BK5ivv+AAhAQg8AAAAAAAYDAQwACgENAAEOAAEPAAEQAAERAAESAAETAAEUAAEVAAEWAAIBDAABARcAAP/UBYr31vbWbDNZMMztjJHjjslD4jkiMq7ifoEn5oTpBnJvdXRlchNuZXdfc3dhcF9jb250ZXh0X3YzAgfbo0Zy4wywZbH5Pjq1Uxh2j9b+9mwVlCyffLhG4vkA5wR1c2RjBFVTREMAB0T4OCGc9nsFjzs3kHtlXyJhU8GOM9/NDaVZqET+qbHBBnVzZHN1aQZVU0RTVUkACAEAAAEBAAECAAEDAAEEAAMBAAAAAQUAAQYAAGJshQlHj+V932nkFBCAsGKREGP1QcVc0OjWAsagh0VzBnR1cmJvcwRzd2FwAwdE+DghnPZ7BY87N5B7ZV8iYVPBjjPfzQ2lWahE/qmxwQZ1c2RzdWkGVVNEU1VJAAfbo0Zy4wywZbH5Pjq1Uxh2j9b+9mwVlCyffLhG4vkA5wR1c2RjBFVTREMAB5G/vDhqQa/P2bJTMFjX6RWh04KQicwmj/QzPVTWM5yhCWZlZTEwMGJwcwlGRUUxMDBCUFMABgICAAEHAAEIAAEJAAEKAAELAAD/1AWK99b21mwzWTDM7YyR447JQ+I5IjKu4n6BJ+aE6QZyb3V0ZXIPY29uZmlybV9zd2FwX3YzAQdE+DghnPZ7BY87N5B7ZV8iYVPBjjPfzQ2lWahE/qmxwQZ1c2RzdWkGVVNEU1VJAAECAgAA/9QFivfW9tZsM1kwzO2MkeOOyUPiOSIyruJ+gSfmhOkGcm91dGVyGHRyYW5zZmVyX29yX2Rlc3Ryb3lfY29pbgEHRPg4IZz2ewWPOzeQe2VfImFTwY4z380NpVmoRP6pscEGdXNkc3VpBlVTRFNVSQABAgQAxvpIp0Vb/0kSQxjVKXMgRBz6wz+DzuqJDEhTct6RffQBE2/Pyiy4OnaUtZVzBUjJjx4j1yCO443gl++NxEWIorB0yIE+AAAAACAzUspPbFn3u58/e0RVnDkrJlwQHqF2usKzWq1aQoRur8b6SKdFW/9JEkMY1SlzIEQc+sM/g87qiQxIU3LekX30ZAAAAAAAAABADQMAAAAAAAA='
+
+/** 1 USDC into NAVI's main market through incentive_v3::entry_deposit. */
+export const CAPTURED_NAVI_DEPOSIT =
+ 'AAATAAhAQg8AAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYBAAAAAAAAAAABAbtOL0tiBcLiottHrrT4MHlux8AF+IU37ndZhmObxEL+XCp9AAAAAAABAQGjWCCXtMV2MARsDEmoi/xrICo+wKnbVZfDF2X3VjdVqBkfMxYAAAAAAQABCgEB+HqKy4uB0UMHiU0SWVVBpz8Zkz+I4TJtW+NJx6b3VZyeYEcCAAAAAAEBAWKYLa0n+xC7MUszhNXejSrC1yqy2+rl2AHb2576gWyAMkeRHQAAAAABAQBbYDds9fgcVUaNcdWHSisDIBWhmr8oYkG3LLi27x1jwBOXfj4AAAAAIDLkcC4gkDG/bFfiIegndKrSCNLG4heRpI+0kL0p7uKIAQBjX6ZQgYVC0YMm4vkhFT8zy7103q9y2tiMZN+w0M8dec6Gfj4AAAAAIFq4DuZ6BNgmQxjs2QNWoGqI+tCcQsk5c0t/ne79j6a6AQDb1/xsM37y/EOaF/PUcang/pdDZsruK6rrKUEcy93/fNm6fT4AAAAAIIGmb4biZm26brkAXD/DgJFJApE61Ix1RRQdTfs3fg5YAQDoS9SI0/uQvPydX8cTdmOMejWhg8LN2nuZT8yfAOTO+MySfj4AAAAAIMgawaKGu8aZ8eivV5l/UOXevXdyIMINFG1GQLOh4+IcAQAAAWLNgTRfTfkhKPSIyW+CZKp5VxdZ14LXvZ1c8vdQ5swKhT4AAAAAIDh6PFPMaWz33Ul0O8BZxBBqkauJIdd+M32POJUzLZYEAQBLI7eDslOaSjpBp38axXpksaO8LlV+l3T0ST9w73geCMa/fT4AAAAAIPsw98BBxRPdQZG/kANeV7Fh/C3JhW7Tr/p0ZnqvBUJ0AQA0GmZIIg6fORZ175KFlXltBQIQzizwrn2vCSakrq2+9q9zfj4AAAAAIDs/5VfX8j9aEt07xyIqVVIq67uFNJhHIHmVmKHfk1RnAQAbw04GQ1xQkGoXPGGfMWIh0KNC81hWcIfidyXGGTLpzHPIgT4AAAAAIDiO/4MG0w/9fJarxuxotLIsC8Y3dyYXZFScTw0M2H7OAQAry+SHVBGgq6/+p5/7hlULbDOcLjNmd/yjXf7skzUaDxSXfj4AAAAAIF856waF38elli7iMecy3IKXDAXrd7slOZ/3/E/Oxcs6AQBFrqRup/dbEPFFObQGjEJ+21xeqJtksXgSS2HcoHPlz07ufT4AAAAAIAFV7XDFxRvyBX/ROY2W4myRWIT4bOhtLZc6y8C3nG48AQDyHizQnW8NtClDoP6LkjKA7b6nnWVAZr+STy5XsXgSrRSXfj4AAAAAIAVXHtFJRR4WxeIJZ5P/06byCYcYvtPZRa1o2BK5ivv+AAhAQg8AAAAAAAMDAQcACgEIAAEJAAEKAAELAAEMAAENAAEOAAEPAAEQAAERAAIBBwABARIAAFEvKCYcGik/SUFtiIW41dMr3m3Wipmgvjb+QrJI5oM6DGluY2VudGl2ZV92Mw1lbnRyeV9kZXBvc2l0AQfbo0Zy4wywZbH5Pjq1Uxh2j9b+9mwVlCyffLhG4vkA5wR1c2RjBFVTREMACAEBAAECAAEDAAEEAAMBAAAAAQAAAQUAAQYAxvpIp0Vb/0kSQxjVKXMgRBz6wz+DzuqJDEhTct6RffQBE2/Pyiy4OnaUtZVzBUjJjx4j1yCO443gl++NxEWIorB0yIE+AAAAACAzUspPbFn3u58/e0RVnDkrJlwQHqF2usKzWq1aQoRur8b6SKdFW/9JEkMY1SlzIEQc+sM/g87qiQxIU3LekX30ZAAAAAAAAAAA6AMAAAAAAAA='
+
+/** 1 SUI into SpringSui: mint, rebalance, transfer the sSUI to the sender. Hand-written Move calls. */
+export const CAPTURED_SPRINGSUI_STAKE =
+ 'AAAFAAgAypo7AAAAAAEBFe2nMwyPmcMOQwtNgv16sq8+rUrhcEb8siSqm605T2sXbmQYAAAAAAEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQAAAAAAAAABAQG7r8stc5nAhG+Bhdo/JzrVsms7NZkwUK/6RM+okPHxROM9bxgAAAAAAQAgxvpIp0Vb/0kSQxjVKXMgRBz6wz+DzuqJDEhTct6RffQEAgABAQAAAEfEtirtksWuMI7NACU7ZLNWitl+b7EeVLPRrF7XvhmrDmxpcXVpZF9zdGFraW5nBG1pbnQBB4NVaJH0oPIzznsFz+f5V9QCBJKjT1QFssuTd9BgvvS/CnNwcmluZ19zdWkKU1BSSU5HX1NVSQADAQEAAQIAAwAAAAAAR8S2Ku2Sxa4wjs0AJTtks1aK2X5vsR5Us9GsXte+GasGd2VpZ2h0CXJlYmFsYW5jZQEHg1VokfSg8jPOewXP5/lX1AIEkqNPVAWyy5N30GC+9L8Kc3ByaW5nX3N1aQpTUFJJTkdfU1VJAAMBAwABAgABAQABAQMBAAAAAQQAxvpIp0Vb/0kSQxjVKXMgRBz6wz+DzuqJDEhTct6RffQBE2/Pyiy4OnaUtZVzBUjJjx4j1yCO443gl++NxEWIorB0yIE+AAAAACAzUspPbFn3u58/e0RVnDkrJlwQHqF2usKzWq1aQoRur8b6SKdFW/9JEkMY1SlzIEQc+sM/g87qiQxIU3LekX30ZAAAAAAAAABsSyMAAAAAAAA='
diff --git a/apps/dapp/lib/chains/sui/cetus-rules.ts b/apps/dapp/lib/chains/sui/cetus-rules.ts
new file mode 100644
index 00000000..52945d6e
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/cetus-rules.ts
@@ -0,0 +1,89 @@
+import { isValidSuiAddress } from '@mysten/sui/utils'
+
+import { ChainActionRefused, type ChainToken } from '../types'
+
+export const MAX_PRICE_IMPACT = 0.03
+export const MAX_VALUE_LOSS = 0.05
+export const SLIPPAGE_BPS = 50
+export const MAX_SLIPPAGE_BPS = 100
+
+const MAX_OVERLAY_BPS = 100
+
+export interface CetusOverlay {
+ receiver: string
+ bps: number
+}
+
+/**
+ * The fee Intent takes on a swap. Both settings are required, so that no rate is
+ * ever assumed on someone's behalf, and a malformed one means no fee at all.
+ */
+export function cetusOverlay(
+ env: Record = process.env
+): CetusOverlay | undefined {
+ const receiver = env['CETUS_OVERLAY_FEE_RECEIVER']?.trim()
+ const bps = Number(env['CETUS_OVERLAY_FEE_BPS']?.trim())
+ if (receiver === undefined || !isValidSuiAddress(receiver)) return undefined
+ if (!Number.isInteger(bps) || bps <= 0 || bps > MAX_OVERLAY_BPS) return undefined
+ return { receiver, bps }
+}
+
+export interface RouteToJudge {
+ requestedIn: string
+ amountIn: string
+ amountOut: string
+ deviationRatio: string | number | undefined
+ tokenIn: ChainToken
+ tokenOut: ChainToken
+ priceIn: number | undefined
+ priceOut: number | undefined
+}
+
+function dollars(value: number): string {
+ return `$${value.toFixed(2)}`
+}
+
+/**
+ * Whether a route is fit to show anyone. The router's own numbers aren't trusted to
+ * flag a thin token: FDUSD on Sui reported $43M of supply while a $100K route paid
+ * back $28.5K. So the output is also valued independently against the input.
+ */
+export function judgeRoute(route: RouteToJudge): void {
+ const { tokenIn, tokenOut, priceIn, priceOut } = route
+ if (route.amountIn !== route.requestedIn) {
+ throw new ChainActionRefused(
+ 'The route found only covers part of that amount, so I stopped here. Try a smaller amount.'
+ )
+ }
+ if (!/^\d+$/.test(route.amountOut) || BigInt(route.amountOut) === BigInt(0)) {
+ throw new ChainActionRefused(
+ `I couldn't find a route from ${tokenIn.symbol} to ${tokenOut.symbol} right now. Try again shortly.`
+ )
+ }
+
+ const impact = route.deviationRatio === undefined ? Number.NaN : Number(route.deviationRatio)
+ if (!Number.isFinite(impact)) {
+ throw new ChainActionRefused(
+ "I couldn't read the price impact of this route, so I won't offer it. Try again shortly."
+ )
+ }
+ if (Math.abs(impact) > MAX_PRICE_IMPACT) {
+ throw new ChainActionRefused(
+ `This swap would move the price by ${(Math.abs(impact) * 100).toFixed(2)}%, over the ${MAX_PRICE_IMPACT * 100}% I allow. Try a smaller amount.`
+ )
+ }
+
+ if (priceIn === undefined || priceOut === undefined) {
+ const missing = priceIn === undefined ? tokenIn.symbol : tokenOut.symbol
+ throw new ChainActionRefused(
+ `I can't get a live price for ${missing} right now, so I can't check this swap is fair. Try again shortly.`
+ )
+ }
+ const valueIn = (Number(route.amountIn) / 10 ** tokenIn.decimals) * priceIn
+ const valueOut = (Number(route.amountOut) / 10 ** tokenOut.decimals) * priceOut
+ if (valueOut < valueIn * (1 - MAX_VALUE_LOSS)) {
+ throw new ChainActionRefused(
+ `This swap would return about ${dollars(valueOut)} for your ${dollars(valueIn)}, more than ${MAX_VALUE_LOSS * 100}% worse than the market price. There isn't enough liquidity, so try a smaller amount or a different token.`
+ )
+ }
+}
diff --git a/apps/dapp/lib/chains/sui/cetus.ts b/apps/dapp/lib/chains/sui/cetus.ts
new file mode 100644
index 00000000..f83bc925
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/cetus.ts
@@ -0,0 +1,120 @@
+import { AggregatorClient } from '@cetusprotocol/aggregator-sdk'
+import { Transaction } from '@mysten/sui/transactions'
+
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+ type SigningPayload,
+} from '../types'
+import { cetusOverlay, judgeRoute, SLIPPAGE_BPS } from './cetus-rules'
+import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client'
+import { CETUS_POLICY, CETUS_PROVIDERS } from './policies'
+import { priceFromLlama } from './price'
+import { assertPtbSafe } from './safety'
+import { displayAmount, SUI_TOKENS } from './tokens'
+
+const QUOTE_TIMEOUT_MS = 10_000
+const VENUE_NAME = 'Cetus Aggregator'
+
+function notes(tokens: string[], slippagePercent: number, feeBps: number | undefined): string[] {
+ const bridged = tokens.includes('USDT')
+ ? ' USDT on Sui is a bridged token, so it also depends on the Sui Bridge.'
+ : ''
+ return [
+ `The amount you get can move before this lands. If it drops by more than ${slippagePercent}%, the swap cancels and you only pay the network fee.`,
+ `This runs through third-party contracts that can fail. Cetus lost about $223M to an exploit in May 2025 and has since relaunched.${bridged}`,
+ ...(feeBps === undefined
+ ? []
+ : [
+ `Intent takes a ${feeBps / 100}% fee from what you receive. The amount shown already includes it.`,
+ ]),
+ ]
+}
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ assertSuiAccount(account)
+ const tokenIn = tokenBySymbol(SUI_TOKENS, request.tokenIn)
+ const tokenOut = tokenBySymbol(SUI_TOKENS, request.tokenOut ?? '')
+ if (tokenIn === undefined || tokenOut === undefined) {
+ throw new ChainActionRefused("I can't confirm those as verified tokens on Sui.")
+ }
+ const requestedIn = toBaseUnits(request.amount, tokenIn.decimals)
+
+ const client = suiClient()
+ const overlay = cetusOverlay()
+ const aggregator = new AggregatorClient({
+ client,
+ signer: account,
+ ...(overlay === undefined
+ ? {}
+ : { overlayFeeRate: overlay.bps / 10_000, overlayFeeReceiver: overlay.receiver }),
+ })
+ const slippage = SLIPPAGE_BPS / 10_000
+
+ const [priceIn, priceOut, route] = await Promise.all([
+ priceFromLlama(tokenIn),
+ priceFromLlama(tokenOut),
+ withTimeout(
+ aggregator.findRouters({
+ from: tokenIn.id,
+ target: tokenOut.id,
+ amount: requestedIn,
+ byAmountIn: true,
+ slippage,
+ providers: CETUS_PROVIDERS,
+ }),
+ QUOTE_TIMEOUT_MS,
+ 'Finding a route'
+ ),
+ ])
+ if (route === null || route.error !== undefined || route.paths.length === 0) {
+ throw new ChainActionRefused(
+ `I couldn't find a route from ${tokenIn.symbol} to ${tokenOut.symbol} right now. Try again shortly.`
+ )
+ }
+ const amountOut = route.amountOut.toString()
+ judgeRoute({
+ requestedIn,
+ amountIn: route.amountIn.toString(),
+ amountOut,
+ deviationRatio: route.deviationRatio,
+ tokenIn,
+ tokenOut,
+ priceIn,
+ priceOut,
+ })
+
+ const tx = new Transaction()
+ tx.setSender(account)
+ await withTimeout(
+ aggregator.fastRouterSwap({ router: route, txb: tx, slippage }),
+ QUOTE_TIMEOUT_MS,
+ 'Building the swap'
+ )
+ const transaction = await buildUnsigned(tx, client, tokenIn.symbol)
+
+ const receive = displayAmount(amountOut, tokenOut.decimals)
+ return {
+ venueId: 'cetus',
+ venueName: VENUE_NAME,
+ kind: 'swap',
+ summary: `Swap ${request.amount} ${tokenIn.symbol} for about ${receive} ${tokenOut.symbol} through Cetus.`,
+ pay: { amount: request.amount, symbol: tokenIn.symbol },
+ receive: { amount: receive, symbol: tokenOut.symbol, approximate: true },
+ notes: notes([tokenIn.symbol, tokenOut.symbol], SLIPPAGE_BPS / 100, overlay?.bps),
+ signing: { family: 'sui', transaction },
+ }
+}
+
+export const cetusVenue: ChainVenue = {
+ id: 'cetus',
+ name: VENUE_NAME,
+ kinds: ['swap'],
+ prepare,
+ assertSafe: (payload: SigningPayload, account: string) =>
+ assertPtbSafe(payload, account, CETUS_POLICY),
+}
diff --git a/apps/dapp/lib/chains/sui/client.ts b/apps/dapp/lib/chains/sui/client.ts
new file mode 100644
index 00000000..ec909eec
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/client.ts
@@ -0,0 +1,73 @@
+import { SuiGrpcClient } from '@mysten/sui/grpc'
+import type { Transaction } from '@mysten/sui/transactions'
+import { isValidSuiAddress, toBase64 } from '@mysten/sui/utils'
+
+import { ChainActionRefused } from '../types'
+
+/** Foundation fullnodes have JSON-RPC turned off, so everything here is gRPC. */
+const DEFAULT_GRPC_URL = 'https://fullnode.mainnet.sui.io:443'
+
+let cached: { url: string; client: SuiGrpcClient } | undefined
+
+export function suiClient(env: Record = process.env): SuiGrpcClient {
+ const url = env['SUI_GRPC_URL']?.trim() || DEFAULT_GRPC_URL
+ if (cached?.url !== url) {
+ cached = { url, client: new SuiGrpcClient({ network: 'mainnet', baseUrl: url }) }
+ }
+ return cached.client
+}
+
+/** The SDKs accept no deadline of their own for most calls, so one is put around them. */
+export function withTimeout(work: Promise, ms: number, what: string): Promise {
+ let timer: ReturnType | undefined
+ const deadline = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms)
+ })
+ return Promise.race([work, deadline]).finally(() => clearTimeout(timer))
+}
+
+export function assertSuiAccount(account: string): void {
+ if (!isValidSuiAddress(account)) {
+ throw new ChainActionRefused('That is not a Sui address. Connect a Sui wallet and try again.')
+ }
+}
+
+/**
+ * A refusal for a build failure the user can act on. Building resolves real coins and
+ * dry-runs the transaction, so a failure here usually says something true about the
+ * wallet. Anything else is left for the route to report as an outage.
+ */
+export function refusalFromBuildError(e: unknown, symbol: string): ChainActionRefused | undefined {
+ const message = e instanceof Error ? e.message : String(e)
+ if (/insufficient ?(coin ?)?balance/i.test(message) && !/\bgas\b/i.test(message)) {
+ return new ChainActionRefused(
+ `You don't have enough ${symbol} in this wallet for that. Check your balance and try a smaller amount.`
+ )
+ }
+ if (/gas/i.test(message) && /(no valid|insufficient|not enough|balance)/i.test(message)) {
+ return new ChainActionRefused(
+ 'This wallet needs a little SUI to pay the network fee. Add some SUI and try again.'
+ )
+ }
+ if (/resolution failed|moveabort|dry.?run|simulat|execution/i.test(message)) {
+ return new ChainActionRefused(
+ 'This transaction would fail if sent right now, so I stopped before asking you to sign. Try again shortly or try a different amount.'
+ )
+ }
+ return undefined
+}
+
+const BUILD_TIMEOUT_MS = 20_000
+
+/** Resolves coins, sets gas and dry-runs on gRPC, then returns base64 bytes. Nothing is signed. */
+export async function buildUnsigned(
+ tx: Transaction,
+ client: SuiGrpcClient,
+ symbol: string
+): Promise {
+ try {
+ return toBase64(await withTimeout(tx.build({ client }), BUILD_TIMEOUT_MS, 'Building'))
+ } catch (e) {
+ throw refusalFromBuildError(e, symbol) ?? e
+ }
+}
diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts
new file mode 100644
index 00000000..2595e9ca
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/module.ts
@@ -0,0 +1,16 @@
+import type { ChainModule } from '../types'
+import { cetusVenue } from './cetus'
+import { naviVenue } from './navi'
+import { priceFromLlama } from './price'
+import { springsuiVenue } from './springsui'
+import { submitSui } from './submit'
+import { SUI_TOKENS } from './tokens'
+
+export const suiModule: ChainModule = {
+ chain: 'sui',
+ tokens: SUI_TOKENS,
+ venues: [cetusVenue, naviVenue, springsuiVenue],
+ defaultVenue: { swap: 'cetus', supply: 'navi', stake: 'springsui' },
+ priceUsd: (token) => priceFromLlama(token),
+ submit: (input) => submitSui(input),
+}
diff --git a/apps/dapp/lib/chains/sui/navi-rules.ts b/apps/dapp/lib/chains/sui/navi-rules.ts
new file mode 100644
index 00000000..e35653d2
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/navi-rules.ts
@@ -0,0 +1,33 @@
+interface PoolRates {
+ supplyIncentiveApyInfo?: { vaultApr?: string; boostedApr?: string } | undefined
+}
+
+/** The current rate as NAVI reports it, or nothing if it can't be read. Never an estimate. */
+export function naviApy(
+ pool: PoolRates
+): { basePercent: number; rewardPercent?: number | undefined } | undefined {
+ const base = Number(pool.supplyIncentiveApyInfo?.vaultApr)
+ const reward = Number(pool.supplyIncentiveApyInfo?.boostedApr)
+ if (!Number.isFinite(base) || pool.supplyIncentiveApyInfo?.vaultApr === undefined) {
+ return undefined
+ }
+ return Number.isFinite(reward) && reward > 0
+ ? { basePercent: base, rewardPercent: reward }
+ : { basePercent: base }
+}
+
+function millions(value: number): string {
+ return `$${(value / 1_000_000).toFixed(1)}M`
+}
+
+export function poolNotes({ availableUsdc }: { availableUsdc: number | undefined }): string[] {
+ const withdrawal =
+ availableUsdc !== undefined && Number.isFinite(availableUsdc)
+ ? `About ${millions(availableUsdc)} of this pool can be withdrawn right now. The rest is lent out, so getting your money back can take longer if many people withdraw at once.`
+ : 'Most of a lending pool is lent out, so getting your money back can take longer if many people withdraw at once.'
+ return [
+ 'This rate is not guaranteed. It moves with borrowing demand, sometimes quickly, and any reward part can end.',
+ withdrawal,
+ 'Money in a lending protocol can be lost if its contracts or price feeds fail.',
+ ]
+}
diff --git a/apps/dapp/lib/chains/sui/navi.ts b/apps/dapp/lib/chains/sui/navi.ts
new file mode 100644
index 00000000..916c79fa
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/navi.ts
@@ -0,0 +1,86 @@
+import { coinWithBalance, Transaction } from '@mysten/sui/transactions'
+import { normalizeStructTag } from '@mysten/sui/utils'
+import { depositCoinPTB, getPools } from '@naviprotocol/lending'
+
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+ type SigningPayload,
+} from '../types'
+import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client'
+import { naviApy, poolNotes } from './navi-rules'
+import { NAVI_POLICY } from './policies'
+import { assertPtbSafe } from './safety'
+import { SUI_TOKENS } from './tokens'
+
+const REQUEST_TIMEOUT_MS = 10_000
+/** How long a pool's rate may be reused. The SDK's default of five minutes is too long to call current. */
+const POOL_CACHE_MS = 30_000
+/** NAVI reports pool amounts with nine decimals, whatever the token's own. */
+const POOL_AMOUNT_SCALE = 1e9
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ assertSuiAccount(account)
+ const token = tokenBySymbol(SUI_TOKENS, request.tokenIn)
+ if (token?.symbol !== 'USDC') {
+ throw new ChainActionRefused(
+ `NAVI takes USDC here, not ${request.tokenIn}. Try "supply 10 USDC on NAVI".`
+ )
+ }
+ const amount = toBaseUnits(request.amount, token.decimals)
+
+ const pools = await withTimeout(
+ getPools({ env: 'prod', cacheTime: POOL_CACHE_MS }),
+ REQUEST_TIMEOUT_MS,
+ "Reading NAVI's pools"
+ )
+ const coinType = normalizeStructTag(token.id)
+ const pool = pools.find((p) => normalizeStructTag(p.suiCoinType) === coinType)
+ if (pool === undefined || pool.isDeprecated) {
+ throw new ChainActionRefused("NAVI's USDC pool isn't open for deposits right now.")
+ }
+ const room = Number(pool.leftSupply)
+ if (Number.isFinite(room) && room < Number(request.amount)) {
+ throw new ChainActionRefused(
+ `NAVI's USDC pool only has room for about ${Math.floor(room)} more USDC. Try a smaller amount.`
+ )
+ }
+
+ const tx = new Transaction()
+ tx.setSender(account)
+ const coin = tx.add(
+ coinWithBalance({ type: token.id, balance: BigInt(amount), useGasCoin: false })
+ )
+ await withTimeout(
+ depositCoinPTB(tx, pool, coin, { env: 'prod', amount: Number(amount) }),
+ REQUEST_TIMEOUT_MS,
+ 'Building the deposit'
+ )
+ const transaction = await buildUnsigned(tx, suiClient(), token.symbol)
+
+ const available =
+ (Number(pool.totalSupplyAmount) - Number(pool.borrowedAmount)) / POOL_AMOUNT_SCALE
+ return {
+ venueId: 'navi',
+ venueName: 'NAVI',
+ kind: 'supply',
+ summary: `Supply ${request.amount} USDC to NAVI's main lending market.`,
+ pay: { amount: request.amount, symbol: token.symbol },
+ apy: naviApy(pool),
+ notes: poolNotes({ availableUsdc: available }),
+ signing: { family: 'sui', transaction },
+ }
+}
+
+export const naviVenue: ChainVenue = {
+ id: 'navi',
+ name: 'NAVI',
+ kinds: ['supply'],
+ prepare,
+ assertSafe: (payload: SigningPayload, account: string) =>
+ assertPtbSafe(payload, account, NAVI_POLICY),
+}
diff --git a/apps/dapp/lib/chains/sui/policies.ts b/apps/dapp/lib/chains/sui/policies.ts
new file mode 100644
index 00000000..b49aa204
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/policies.ts
@@ -0,0 +1,170 @@
+import { bcs } from '@mysten/sui/bcs'
+import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'
+
+import { ChainActionRefused } from '../types'
+import { cetusOverlay, MAX_SLIPPAGE_BPS } from './cetus-rules'
+import { moveTarget, type ParsedCall, type PtbPolicy, type ReadPure } from './safety'
+import { SUI_TOKENS } from './tokens'
+
+/**
+ * Cetus's aggregator router. This id is the SDK's own `DEFAULT_PUBLISHED_AT.Mainnet`
+ * (aggregator-sdk 1.7.5), and the live router API returns the same one.
+ */
+export const CETUS_ROUTER = '0xffd4058af7d6f6d66c335930cced8c91e38ec943e2392232aee27e8127e684e9'
+
+/**
+ * Swap wrappers for the pools routes may use, with the router API's `providers`
+ * names. The SDK doesn't list these: it takes `published_at` from the API. So each
+ * was read from live routes and checked on chain: published by the router's own
+ * deployer (0x2891c6b8...2c47), and `swap` takes `&mut router::SwapContext` from the
+ * router's original package (0x33ec64e9...). Their other public functions were read
+ * too; none of the listed `swap` functions takes a recipient.
+ *
+ * Momentum, Aftermath and Ferra wrappers are left out because a different address
+ * published them, and Magma and Bolt because the research didn't cover them. Oracle
+ * pools are left out so a swap never needs Pyth price updates.
+ */
+export const CETUS_WRAPPERS = {
+ CETUS: ['0x87b17d7f0b448b83ab934a13eb64b05b60e17d0a371e2a721c241bcf3f5ec9e7', 'cetus'],
+ CETUSDLMM: ['0x8e094a372cdea3af6ccb58a5fd70689f3000b6101777b24db980fa4e3d740861', 'cetus_dlmm'],
+ CETUS_TIDE: ['0xe67d0eb102fc781926ab7e91272e92661baf629653e0525f9b637f9ae5a9218a', 'orderbook'],
+ BLUEFIN: ['0x4e7c4ba436f8fd5b3c6bb514880ccd11c5109c83c45b5e037394b94204dbbb80', 'bluefin'],
+ TURBOS: ['0x626c8509478fe57ddf69e4141080b062911063f541c55cd0e8d602c6a0874573', 'turbos'],
+ FLOWXV3: ['0x66d7bfb7ca9002c4e0a2a8bfb763ca224011be912f24025a52a4e19be0cb0ac0', 'flowx_clmm'],
+} as const
+
+export const CETUS_PROVIDERS = Object.keys(CETUS_WRAPPERS)
+
+/**
+ * NAVI's lending package (version 26). `getConfig` returns it from NAVI's API, and
+ * on chain its `incentive_v3::entry_deposit` takes the `storage::Storage` and
+ * `pool::Pool` types that NAVI's live shared objects have, from the original package
+ * 0xd899cf7d...81ca. The live test fails when NAVI's config moves away from this id.
+ */
+export const NAVI_PACKAGE = '0x512f28261c1a293f49416d8885b8d5d32bde6dd68a99a0be36fe42b248e6833a'
+
+const NEW_SWAP_CONTEXT = moveTarget(CETUS_ROUTER, 'router', 'new_swap_context_v3')
+/** The contract counts slippage and fees in millionths; a basis point is 100 of them. */
+const CONTRACT_UNITS_PER_BPS = 100
+const LISTED_COINS: ReadonlySet = new Set(SUI_TOKENS.map((t) => normalizeStructTag(t.id)))
+
+function fail(reason: string): never {
+ throw new ChainActionRefused(`${reason} Nothing was signed or sent.`)
+}
+
+function readNumber(
+ read: ReadPure,
+ call: ParsedCall,
+ index: number,
+ type: 'u32' | 'u64',
+ label: string
+): number {
+ const bytes = read(call.arguments[index])
+ if (bytes === undefined) return fail(`I couldn't read the ${label} on this swap.`)
+ try {
+ return Number(type === 'u32' ? bcs.u32().parse(bytes) : bcs.u64().parse(bytes))
+ } catch {
+ return fail(`I couldn't read the ${label} on this swap.`)
+ }
+}
+
+/**
+ * The router's public entry point takes a fee rate, a fee recipient and a slippage
+ * limit, so an allowed contract could still pay someone else or give up the price
+ * protection. Arguments: quote id, max in, quote in, quote out, slippage, coin, fee
+ * rate, fee recipient.
+ */
+function checkSwapContext(call: ParsedCall, read: ReadPure): void {
+ if (call.target !== NEW_SWAP_CONTEXT) return
+
+ for (const coin of call.typeArguments) {
+ if (!LISTED_COINS.has(normalizeStructTag(coin))) {
+ fail('This swap involves a token outside the verified list.')
+ }
+ }
+
+ const slippage = readNumber(read, call, 4, 'u64', 'slippage limit')
+ if (slippage > MAX_SLIPPAGE_BPS * CONTRACT_UNITS_PER_BPS) {
+ fail(`This swap's slippage limit is over the ${MAX_SLIPPAGE_BPS / 100}% I allow.`)
+ }
+
+ const feeRate = readNumber(read, call, 6, 'u32', 'fee')
+ if (feeRate === 0) return
+ const overlay = cetusOverlay()
+ if (overlay === undefined || feeRate > overlay.bps * CONTRACT_UNITS_PER_BPS) {
+ fail('This swap charges a fee that was not agreed.')
+ }
+ const recipient = read(call.arguments[7])
+ let paid: string | undefined
+ try {
+ paid = recipient === undefined ? undefined : normalizeSuiAddress(bcs.Address.parse(recipient))
+ } catch {
+ paid = undefined
+ }
+ if (paid !== normalizeSuiAddress(overlay.receiver)) {
+ fail('This swap pays its fee to an address that was not agreed.')
+ }
+}
+
+/**
+ * SpringSui's latest package: the `package` field of the UpgradeCap the SDK reads
+ * (springsui-sdk 4.1.0, `SPRING_SUI_UPGRADE_CAP_ID` 0x4dc657b6...cd67, version 10).
+ * The call names and argument order are the SDK's generated bindings. The sSUI object
+ * ids are read from SpringSui's on-chain registry (0x06d6b688...a3f4), not assumed.
+ */
+export const SPRINGSUI_PACKAGE =
+ '0x47c4b62aed92c5ae308ecd00253b64b3568ad97e6fb11e54b3d1ac5ed7be19ab'
+export const SSUI_TYPE =
+ '0x83556891f4a0f233ce7b05cfe7f957d4020492a34f5405b2cb9377d060bef4bf::spring_sui::SPRING_SUI'
+export const SSUI_INFO = '0x15eda7330c8f99c30e430b4d82fd7ab2af3ead4ae17046fcb224aa9bad394f6b'
+export const SSUI_WEIGHT_HOOK = '0xbbafcb2d7399c0846f8185da3f273ad5b26b3b35993050affa44cfa890f1f144'
+
+/** Anyone can create a token in this package, so the allowed call says nothing about the coin. */
+function checkMintsSsui(call: ParsedCall): void {
+ for (const coin of call.typeArguments) {
+ if (normalizeStructTag(coin) !== normalizeStructTag(SSUI_TYPE)) {
+ fail('This stake mints a token other than sSUI.')
+ }
+ }
+}
+
+/** How much sSUI a dry run paid this account, in base units. */
+export function mintedAmount(
+ changes: readonly { coinType: string; address: string; amount: string }[] | undefined,
+ account: string
+): string | undefined {
+ const paid = changes?.find(
+ (c) =>
+ normalizeStructTag(c.coinType) === normalizeStructTag(SSUI_TYPE) &&
+ normalizeSuiAddress(c.address) === normalizeSuiAddress(account)
+ )
+ return paid !== undefined && /^\d+$/.test(paid.amount) && BigInt(paid.amount) > BigInt(0)
+ ? paid.amount
+ : undefined
+}
+
+export const SPRINGSUI_POLICY: PtbPolicy = {
+ venueName: 'SpringSui',
+ allowedCalls: new Set([
+ moveTarget(SPRINGSUI_PACKAGE, 'liquid_staking', 'mint'),
+ moveTarget(SPRINGSUI_PACKAGE, 'weight', 'rebalance'),
+ ]),
+ checkCall: checkMintsSsui,
+}
+
+export const CETUS_POLICY: PtbPolicy = {
+ venueName: 'Cetus',
+ allowedCalls: new Set([
+ NEW_SWAP_CONTEXT,
+ moveTarget(CETUS_ROUTER, 'router', 'confirm_swap_v3'),
+ // Takes only the coin and pays the transaction's sender.
+ moveTarget(CETUS_ROUTER, 'router', 'transfer_or_destroy_coin'),
+ ...Object.values(CETUS_WRAPPERS).map(([pkg, module]) => moveTarget(pkg, module, 'swap')),
+ ]),
+ checkCall: checkSwapContext,
+}
+
+export const NAVI_POLICY: PtbPolicy = {
+ venueName: 'NAVI',
+ allowedCalls: new Set([moveTarget(NAVI_PACKAGE, 'incentive_v3', 'entry_deposit')]),
+}
diff --git a/apps/dapp/lib/chains/sui/price.ts b/apps/dapp/lib/chains/sui/price.ts
new file mode 100644
index 00000000..e9c40cca
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/price.ts
@@ -0,0 +1,51 @@
+import type { ChainToken } from '../types'
+import { STABLECOIN_SYMBOLS, SUI_TOKENS } from './tokens'
+
+const PRICE_URL = 'https://coins.llama.fi/prices/current'
+const TIMEOUT_MS = 5_000
+const PEG_TOLERANCE = 0.005
+const MIN_CONFIDENCE = 0.9
+/** Stablecoin prices here update less often than SUI's, and matter less per minute. */
+const MAX_AGE_STABLE_SECONDS = 3_600
+const MAX_AGE_SECONDS = 300
+
+interface LlamaCoin {
+ price?: unknown
+ timestamp?: unknown
+ confidence?: unknown
+}
+
+/**
+ * Dollars per unit from DefiLlama, keyed by the exact coin type rather than a symbol,
+ * or nothing, in which case the trade is refused. Pyth's public Hermes endpoint
+ * answered 401 when this was written, so it isn't used.
+ */
+export async function priceFromLlama(
+ token: ChainToken,
+ fetcher: typeof fetch = fetch,
+ now: number = Date.now()
+): Promise {
+ if (!SUI_TOKENS.some((t) => t.id === token.id)) return undefined
+ const key = `sui:${token.id}`
+ try {
+ const response = await fetcher(`${PRICE_URL}/${key}`, {
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ })
+ if (!response.ok) return undefined
+ const body = (await response.json()) as { coins?: Record }
+ const coin = body.coins?.[key]
+ const price = Number(coin?.price)
+ const timestamp = Number(coin?.timestamp)
+ const confidence = Number(coin?.confidence)
+ if (!Number.isFinite(price) || price <= 0) return undefined
+ if (!Number.isFinite(confidence) || confidence < MIN_CONFIDENCE) return undefined
+
+ const stable = STABLECOIN_SYMBOLS.has(token.symbol)
+ const maxAge = stable ? MAX_AGE_STABLE_SECONDS : MAX_AGE_SECONDS
+ if (!Number.isFinite(timestamp) || now / 1000 - timestamp > maxAge) return undefined
+
+ return stable && Math.abs(price - 1) <= PEG_TOLERANCE ? 1 : price
+ } catch {
+ return undefined
+ }
+}
diff --git a/apps/dapp/lib/chains/sui/safety.ts b/apps/dapp/lib/chains/sui/safety.ts
new file mode 100644
index 00000000..763b33c7
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/safety.ts
@@ -0,0 +1,145 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, isValidSuiAddress, normalizeSuiAddress } from '@mysten/sui/utils'
+
+import { ChainActionRefused, type SigningPayload } from '../types'
+
+type TxData = ReturnType
+type TxCommand = TxData['commands'][number]
+type MoveCall = Extract['MoveCall']
+type TxArgument = MoveCall['arguments'][number]
+
+export interface ParsedCall {
+ target: string
+ typeArguments: readonly string[]
+ arguments: readonly TxArgument[]
+}
+
+/** The bytes of a literal argument, or nothing when the value comes from elsewhere in the transaction. */
+export type ReadPure = (argument: TxArgument | undefined) => Uint8Array | undefined
+
+export interface PtbPolicy {
+ venueName: string
+ /** Normalized `package::module::function`. A package alone is too coarse: see below. */
+ allowedCalls: ReadonlySet
+ /** Checks the arguments of an allowed call, where they can name a recipient or a limit. */
+ checkCall?: (call: ParsedCall, read: ReadPure, account: string) => void
+}
+
+export function moveTarget(pkg: string, module: string, fn: string): string {
+ return `${normalizeSuiAddress(pkg)}::${module}::${fn}`
+}
+
+const SEND_FUNDS = moveTarget('0x2', 'coin', 'send_funds')
+
+/**
+ * What `coinWithBalance` emits for an account with address-balance funds. Named one
+ * by one, because allowing the framework package would allow
+ * `0x2::transfer::public_transfer`, which sends anything anywhere without ever
+ * appearing as a TransferObjects command.
+ */
+const FRAMEWORK_CALLS: ReadonlySet = new Set([
+ moveTarget('0x2', 'coin', 'redeem_funds'),
+ moveTarget('0x2', 'coin', 'destroy_zero'),
+ SEND_FUNDS,
+])
+
+const NOTHING_SENT = 'Nothing was signed or sent.'
+
+function refuse(reason: string): never {
+ throw new ChainActionRefused(`${reason} ${NOTHING_SENT}`)
+}
+
+function parse(transaction: string): TxData {
+ // Only BCS bytes. The JSON form can hold unresolved values that are not what gets signed.
+ if (transaction === '' || !/^[A-Za-z0-9+/]+={0,2}$/.test(transaction)) {
+ refuse('I could not read this as a Sui transaction.')
+ }
+ try {
+ return Transaction.from(fromBase64(transaction)).getData()
+ } catch {
+ return refuse('I could not read this as a Sui transaction.')
+ }
+}
+
+function readerFor(data: TxData): ReadPure {
+ return (argument) => {
+ if (argument === undefined || argument.$kind !== 'Input') return undefined
+ const input = data.inputs[argument.Input]
+ if (input?.$kind !== 'Pure') return undefined
+ return fromBase64(input.Pure.bytes)
+ }
+}
+
+function assertPaysAccount(read: ReadPure, destination: TxArgument | undefined, me: string): void {
+ const bytes = read(destination)
+ let recipient: string | undefined
+ try {
+ recipient = bytes === undefined ? undefined : normalizeSuiAddress(bcs.Address.parse(bytes))
+ } catch {
+ recipient = undefined
+ }
+ if (recipient !== me) refuse('This transaction would send funds to another address.')
+}
+
+/**
+ * Refuses anything but what the venue is confirmed to do, on what the wallet would
+ * actually sign. It runs before the user sees a transaction and again on the signed
+ * bytes, because the venue's API and the browser are outside this server's control.
+ *
+ * A protocol upgrade publishes a new package id. An id not listed here is refused,
+ * so an upgrade blocks the venue until someone confirms the new id and adds it. It
+ * cannot widen what is trusted on its own.
+ */
+export function assertPtbSafe(payload: SigningPayload, account: string, policy: PtbPolicy): void {
+ if (payload.family !== 'sui') refuse('That is not a Sui transaction.')
+ if (!isValidSuiAddress(account)) refuse('That is not a Sui address.')
+ const me = normalizeSuiAddress(account)
+ const data = parse(payload.transaction)
+ const read = readerFor(data)
+
+ if (typeof data.sender === 'string' && normalizeSuiAddress(data.sender) !== me) {
+ refuse('This transaction would be sent from a different account than your wallet.')
+ }
+ const gasOwner = data.gasData.owner
+ if (typeof gasOwner === 'string' && normalizeSuiAddress(gasOwner) !== me) {
+ refuse('The network fee on this transaction is set to be paid by a different account.')
+ }
+
+ for (const input of data.inputs) {
+ if (
+ input.$kind === 'FundsWithdrawal' &&
+ input.FundsWithdrawal.withdrawFrom.$kind !== 'Sender'
+ ) {
+ refuse('This transaction draws funds from someone other than the sender.')
+ }
+ }
+
+ for (const command of data.commands) {
+ switch (command.$kind) {
+ case 'SplitCoins':
+ case 'MergeCoins':
+ break
+ case 'TransferObjects':
+ assertPaysAccount(read, command.TransferObjects.address, me)
+ break
+ case 'MoveCall': {
+ const { package: pkg, module, function: fn, typeArguments } = command.MoveCall
+ const target = moveTarget(pkg, module, fn)
+ if (!policy.allowedCalls.has(target) && !FRAMEWORK_CALLS.has(target)) {
+ refuse(
+ `This transaction calls ${module}::${fn} in a contract I haven't confirmed for ${policy.venueName}. If ${policy.venueName} upgraded its contracts, this stays blocked until the new ones are checked.`
+ )
+ }
+ const args = command.MoveCall.arguments
+ if (target === SEND_FUNDS) assertPaysAccount(read, args[1], me)
+ policy.checkCall?.({ target, typeArguments, arguments: args }, read, me)
+ break
+ }
+ default:
+ refuse(
+ `This transaction does more than ${policy.venueName} needs (a ${command.$kind} step).`
+ )
+ }
+ }
+}
diff --git a/apps/dapp/lib/chains/sui/sign.ts b/apps/dapp/lib/chains/sui/sign.ts
new file mode 100644
index 00000000..fec66b89
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/sign.ts
@@ -0,0 +1,30 @@
+import type { Wallet, WalletAccount } from '@wallet-standard/base'
+
+import type { SignedPayload, SigningPayload } from '../types'
+
+interface SuiSignTransaction {
+ 'sui:signTransaction'?: {
+ signTransaction: (input: {
+ transaction: { toJSON: () => Promise }
+ account: WalletAccount
+ chain: string
+ }) => Promise<{ bytes: string; signature: string }>
+ }
+}
+
+/** The Wallet Standard's Sui signing feature. The server checks and sends the result. */
+export async function signSui(
+ wallet: Wallet,
+ account: WalletAccount,
+ payload: SigningPayload
+): Promise {
+ if (payload.family !== 'sui') throw new Error('That is not a Sui transaction.')
+ const feature = (wallet.features as SuiSignTransaction)['sui:signTransaction']
+ if (feature === undefined) throw new Error(`${wallet.name} cannot sign Sui transactions here.`)
+ const { bytes, signature } = await feature.signTransaction({
+ transaction: { toJSON: async () => payload.transaction },
+ account,
+ chain: 'sui:mainnet',
+ })
+ return { family: 'sui', transaction: bytes, signature }
+}
diff --git a/apps/dapp/lib/chains/sui/springsui.ts b/apps/dapp/lib/chains/sui/springsui.ts
new file mode 100644
index 00000000..9c4bfce9
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/springsui.ts
@@ -0,0 +1,96 @@
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64 } from '@mysten/sui/utils'
+
+import {
+ ChainActionRefused,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+ type SigningPayload,
+} from '../types'
+import { assertSuiAccount, buildUnsigned, suiClient, withTimeout } from './client'
+import {
+ mintedAmount,
+ SPRINGSUI_PACKAGE,
+ SPRINGSUI_POLICY,
+ SSUI_INFO,
+ SSUI_TYPE,
+ SSUI_WEIGHT_HOOK,
+} from './policies'
+import { assertPtbSafe } from './safety'
+import { displayAmount } from './tokens'
+
+const SUI_SYSTEM_STATE = '0x5'
+const SUI_DECIMALS = 9
+const SSUI_DECIMALS = 9
+const DRY_RUN_TIMEOUT_MS = 15_000
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ assertSuiAccount(account)
+ if (request.tokenIn.trim().toUpperCase() !== 'SUI') {
+ throw new ChainActionRefused(
+ `SpringSui stakes SUI, not ${request.tokenIn}. Try "stake 1 SUI with SpringSui".`
+ )
+ }
+ const amount = toBaseUnits(request.amount, SUI_DECIMALS)
+
+ const tx = new Transaction()
+ tx.setSender(account)
+ const [sui] = tx.splitCoins(tx.gas, [BigInt(amount)])
+ if (sui === undefined) throw new Error('Splitting a coin returned nothing.')
+ const [minted] = tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::liquid_staking::mint`,
+ typeArguments: [SSUI_TYPE],
+ arguments: [tx.object(SSUI_INFO), tx.object(SUI_SYSTEM_STATE), sui],
+ })
+ if (minted === undefined) throw new Error('Minting returned nothing.')
+ tx.moveCall({
+ target: `${SPRINGSUI_PACKAGE}::weight::rebalance`,
+ typeArguments: [SSUI_TYPE],
+ arguments: [tx.object(SSUI_WEIGHT_HOOK), tx.object(SUI_SYSTEM_STATE), tx.object(SSUI_INFO)],
+ })
+ tx.transferObjects([minted], account)
+
+ const client = suiClient()
+ const transaction = await buildUnsigned(tx, client, 'SUI')
+ const simulated = await withTimeout(
+ client.core.simulateTransaction({
+ transaction: fromBase64(transaction),
+ include: { balanceChanges: true },
+ }),
+ DRY_RUN_TIMEOUT_MS,
+ 'Checking the stake'
+ )
+ const received = mintedAmount(simulated.Transaction?.balanceChanges, account)
+ if (received === undefined) {
+ throw new ChainActionRefused(
+ "I couldn't confirm how much sSUI this stake would return, so I stopped. Nothing was signed or sent. Try again shortly."
+ )
+ }
+
+ const receive = displayAmount(received, SSUI_DECIMALS)
+ return {
+ venueId: 'springsui',
+ venueName: 'SpringSui',
+ kind: 'stake',
+ summary: `Stake ${request.amount} SUI with SpringSui for about ${receive} sSUI.`,
+ pay: { amount: request.amount, symbol: 'SUI' },
+ receive: { amount: receive, symbol: 'sSUI', approximate: true },
+ notes: [
+ 'Staking rewards are not guaranteed. They depend on the validators, and I am not showing a rate because I could not read one from SpringSui.',
+ 'sSUI is worth more than SUI over time, so you get fewer of them. Converting back has a small fee, and sSUI can trade below its value on the market.',
+ 'Your SUI sits in third-party contracts that can fail. SpringSui shares an operator group with Suilend, Bluefin and Ember.',
+ ],
+ signing: { family: 'sui', transaction },
+ }
+}
+
+export const springsuiVenue: ChainVenue = {
+ id: 'springsui',
+ name: 'SpringSui',
+ kinds: ['stake'],
+ prepare,
+ assertSafe: (payload: SigningPayload, account: string) =>
+ assertPtbSafe(payload, account, SPRINGSUI_POLICY),
+}
diff --git a/apps/dapp/lib/chains/sui/submit.ts b/apps/dapp/lib/chains/sui/submit.ts
new file mode 100644
index 00000000..8c02175c
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/submit.ts
@@ -0,0 +1,82 @@
+import { fromBase64 } from '@mysten/sui/utils'
+
+import type { SubmitInput, SubmitResult } from '../types'
+import { suiClient } from './client'
+
+const EXECUTE_TIMEOUT_MS = 45_000
+
+interface ExecutedTx {
+ digest: string
+ effects?: { status?: { success: boolean; error?: { message: string } | null } }
+}
+
+export interface SuiExecutor {
+ executeTransaction(options: {
+ transaction: Uint8Array
+ signatures: string[]
+ include: { effects: true }
+ signal: AbortSignal
+ }): Promise<{ Transaction?: ExecutedTx; FailedTransaction?: ExecutedTx }>
+}
+
+/** Codes meaning the network turned it down, as opposed to not answering. */
+const REJECTED_CODES = new Set(['INVALID_ARGUMENT', 'FAILED_PRECONDITION'])
+const STALE_OBJECT = /not available for consumption|ObjectVersionUnavailable|locked|too old/i
+
+/**
+ * A plain outcome for a rejection that certainly didn't land, or nothing. A timeout or
+ * dropped connection stays an exception, because the transaction may still have gone
+ * through, and the route tells the user to check their wallet.
+ */
+function rejection(e: unknown): string | undefined {
+ const message = e instanceof Error ? e.message : ''
+ if (STALE_OBJECT.test(message)) {
+ return "This wallet's coins changed after the transaction was built. Nothing moved. Please try again."
+ }
+ const code = (e as { code?: unknown } | undefined)?.code
+ if (typeof code === 'string' && REJECTED_CODES.has(code)) {
+ return 'The network rejected this transaction. Nothing moved. Please try again, and if it keeps happening, reconnect your wallet.'
+ }
+ return undefined
+}
+
+export async function submitSui(input: SubmitInput, executor?: SuiExecutor): Promise {
+ const { signed } = input
+ if (signed.family !== 'sui') return { ok: false, error: 'That is not a Sui transaction.' }
+
+ const client: SuiExecutor = executor ?? {
+ executeTransaction: (options) => suiClient().core.executeTransaction(options),
+ }
+
+ let result: Awaited>
+ try {
+ result = await client.executeTransaction({
+ transaction: fromBase64(signed.transaction),
+ signatures: [signed.signature],
+ include: { effects: true },
+ signal: AbortSignal.timeout(EXECUTE_TIMEOUT_MS),
+ })
+ } catch (e) {
+ const error = rejection(e)
+ if (error !== undefined) return { ok: false, error }
+ throw e
+ }
+
+ const executed = result.Transaction ?? result.FailedTransaction
+ const status = executed?.effects?.status
+ if (executed === undefined || status === undefined) {
+ throw new Error('The network returned no effects for this transaction.')
+ }
+ if (!status.success) {
+ return {
+ ok: false,
+ error:
+ 'This transaction failed on the network, so the trade did not happen and only the network fee was spent. The price may have moved. Please try again.',
+ }
+ }
+ return {
+ ok: true,
+ hash: executed.digest,
+ explorerUrl: `https://suiscan.xyz/mainnet/tx/${executed.digest}`,
+ }
+}
diff --git a/apps/dapp/lib/chains/sui/tokens.ts b/apps/dapp/lib/chains/sui/tokens.ts
new file mode 100644
index 00000000..1368491c
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/tokens.ts
@@ -0,0 +1,40 @@
+import type { ChainToken } from '../types'
+
+/**
+ * The only coins anything here will touch. Coin types and decimals were read from
+ * each coin's on-chain metadata; USDC also matches Circle's published address. A
+ * different coin can call itself USDT on Sui, and several do, so nothing is ever
+ * resolved by symbol outside this list.
+ */
+export const SUI_TOKENS: readonly ChainToken[] = [
+ { symbol: 'SUI', id: '0x2::sui::SUI', decimals: 9, name: 'Sui' },
+ {
+ symbol: 'USDC',
+ id: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC',
+ decimals: 6,
+ name: 'USD Coin (native)',
+ },
+ {
+ symbol: 'USDsui',
+ id: '0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::USDSUI',
+ decimals: 6,
+ name: 'Sui Dollar',
+ },
+ {
+ symbol: 'USDT',
+ id: '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT',
+ decimals: 6,
+ name: 'Tether (Sui Bridge)',
+ },
+]
+
+export const STABLECOIN_SYMBOLS: ReadonlySet = new Set(['USDC', 'USDsui', 'USDT'])
+
+/** Display text for base units, rounded down so a quote never reads higher than it is. */
+export function displayAmount(base: string, decimals: number, places = 6): string {
+ const padded = base.padStart(decimals + 1, '0')
+ const whole = padded.slice(0, padded.length - decimals)
+ const fraction = padded.slice(padded.length - decimals, padded.length - decimals + places)
+ const trimmed = fraction.replace(/0+$/, '')
+ return trimmed === '' ? whole : `${whole}.${trimmed}`
+}
diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts
new file mode 100644
index 00000000..3d402ea5
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/venues.ts
@@ -0,0 +1,124 @@
+import type { Venue } from '@intent/types'
+
+function listed(
+ id: string,
+ name: string,
+ category: Venue['category'],
+ url: string,
+ bestFor: string
+): Venue {
+ return { id, family: 'sui', name, category, chains: ['Sui'], url, bestFor }
+}
+
+export const suiVenues: Venue[] = [
+ listed(
+ 'linq-sui',
+ 'Linq (Sui USDC, USDsui)',
+ 'offramp',
+ 'https://switch.uselinq.xyz',
+ 'Naira cash-out from Sui. Not connected yet: needs Linq to confirm Sui and a separate payment path'
+ ),
+ {
+ id: 'cetus',
+ family: 'sui',
+ name: 'Cetus Aggregator',
+ category: 'aggregator',
+ chains: ['Sui'],
+ url: 'https://app.cetus.zone',
+ bestFor: "Swaps at the best route across Sui's main pools",
+ integration: 'executes',
+ capability: 'Swaps SUI, USDC, USDsui and USDT',
+ },
+ {
+ id: 'navi',
+ family: 'sui',
+ name: 'NAVI',
+ category: 'lending',
+ chains: ['Sui'],
+ url: 'https://app.naviprotocol.io',
+ bestFor: 'Earning on USDC, at a variable rate that can move quickly',
+ integration: 'executes',
+ capability: 'Supplies USDC and shows the current rate',
+ },
+ listed(
+ 'bluefin-aggregator',
+ 'Bluefin Aggregator',
+ 'aggregator',
+ 'https://trade.bluefin.io/swap',
+ 'Swaps with quotes from market makers, formerly 7K'
+ ),
+ listed(
+ 'suilend',
+ 'Suilend',
+ 'lending',
+ 'https://suilend.fi',
+ 'Lending in the largest market on Sui, under new ownership since June 2026'
+ ),
+ listed(
+ 'current-finance',
+ 'Current Finance',
+ 'lending',
+ 'https://current.finance',
+ 'Larger USDC lending market, where much of the rate is rewards that can end'
+ ),
+ listed(
+ 'payment-kit',
+ 'Sui Payment Kit',
+ 'payments',
+ 'https://docs.sui.io/standards/payment-kit',
+ 'Payment links and receipts, with USDsui or any coin. Still a 0.x release'
+ ),
+ listed(
+ 'ember',
+ 'Ember',
+ 'savings',
+ 'https://ember.so',
+ 'Higher-yield vaults with multi-day withdrawals and mixed strategies. Not plain savings'
+ ),
+ listed(
+ 'bucket',
+ 'Bucket',
+ 'savings',
+ 'https://www.bucketprotocol.io',
+ 'USDB savings, though the current rate may not compete'
+ ),
+ listed(
+ 'deepbook',
+ 'DeepBook',
+ 'orderbook',
+ 'https://www.deepbook.tech',
+ "Sui's on-chain order book, for limit orders"
+ ),
+ {
+ id: 'springsui',
+ family: 'sui',
+ name: 'SpringSui',
+ category: 'staking',
+ chains: ['Sui'],
+ url: 'https://www.springsui.com',
+ bestFor: 'Liquid staking with the largest pool on Sui',
+ integration: 'executes',
+ capability: 'Stakes SUI for sSUI',
+ },
+ listed(
+ 'haedal',
+ 'Haedal',
+ 'staking',
+ 'https://www.haedal.xyz',
+ 'Liquid staking with haSUI. Its vaults lost about $915K in June 2026; staking reportedly was not affected'
+ ),
+ listed(
+ 'aftermath',
+ 'Aftermath',
+ 'aggregator',
+ 'https://aftermath.finance',
+ 'Swaps and afSUI staking. Its perps lost about $1.14M in April 2026'
+ ),
+ listed(
+ 'scallop',
+ 'Scallop',
+ 'lending',
+ 'https://scallop.io',
+ 'Lending with a small USDC pool. A retired contract lost about $142K in April 2026'
+ ),
+]
diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts
new file mode 100644
index 00000000..26f8d782
--- /dev/null
+++ b/apps/dapp/lib/chains/types.ts
@@ -0,0 +1,145 @@
+import type { ChainSlug } from '@intent/types'
+
+/**
+ * Doing something on a chain that is not Stellar, in three moves: prepare on the
+ * server, sign in the wallet, submit. Each chain supplies a `ChainModule`; the
+ * routes, the hook and the chat card know nothing about any chain in particular.
+ */
+
+export type ChainWithActions = Extract
+
+export type ChainActionKind = 'swap' | 'supply' | 'stake'
+
+/** What the user asked for. Amounts are decimal text in display units. */
+export interface ChainActionRequest {
+ kind: ChainActionKind
+ amount: string
+ tokenIn: string
+ /** Swaps only. */
+ tokenOut?: string | undefined
+ /** A venue the user named. Absent means the chain's default for this kind. */
+ venue?: string | undefined
+}
+
+export interface NearTransaction {
+ receiverId: string
+ actions: {
+ type: 'FunctionCall'
+ params: { methodName: string; args: Record; gas: string; deposit: string }
+ }[]
+}
+
+/** What the wallet is asked to sign. Base64 for Solana and Sui. */
+export type SigningPayload =
+ | { family: 'solana'; transaction: string }
+ | { family: 'sui'; transaction: string }
+ | { family: 'near'; transactions: NearTransaction[] }
+ | { family: 'evm'; chainId: number; to: string; data: string; value: '0' }
+ | { family: 'stellar'; xdr: string; account: string }
+
+/**
+ * What a wallet gives back. Solana, Sui and Stellar wallets return signed bytes the
+ * server checks and submits. NEAR and EVM wallets send the transactions themselves.
+ */
+export type SignedPayload =
+ | { family: 'solana'; signedTransaction: string }
+ | { family: 'sui'; transaction: string; signature: string }
+ | { family: 'near'; hashes: string[] }
+ | { family: 'evm'; hash: string }
+ | { family: 'stellar'; signedXdr: string }
+
+export type SignedOutcome =
+ | { ok: true; signed: SignedPayload }
+ | { ok: false; reason: 'rejected' | 'wallet_error'; detail?: string }
+
+export interface PreparedAction {
+ venueId: string
+ venueName: string
+ kind: ChainActionKind
+ /** One sentence: what this does. */
+ summary: string
+ pay: { amount: string; symbol: string }
+ /** What comes back. `approximate` when the amount can move before it lands. */
+ receive?: { amount: string; symbol: string; approximate: boolean } | undefined
+ /** Current rate, for savings and staking. Base and reward parts stay separate. */
+ apy?: { basePercent: number; rewardPercent?: number | undefined } | undefined
+ /** Plain-language risks and conditions the user reads before signing. */
+ notes: string[]
+ signing: SigningPayload
+ /** Returned unchanged at submit, for venues whose submit step needs it. */
+ context?: Record | undefined
+}
+
+export interface SubmitInput {
+ account: string
+ venueId: string
+ signed: SignedPayload
+ context?: Record | undefined
+}
+
+export type SubmitResult =
+ | { ok: true; hash: string; explorerUrl: string }
+ /**
+ * `pending`: sent, but the outcome isn't known yet. The money has moved, so
+ * this is not a failure, and the person must be told not to send it again.
+ */
+ | { ok: false; error: string; pending?: boolean }
+
+export interface ChainToken {
+ symbol: string
+ /** The chain's own identifier: mint, coin type or NEP-141 account. */
+ id: string
+ decimals: number
+ name?: string | undefined
+}
+
+/** Raised for a refusal the user should read, such as an unknown token or thin liquidity. */
+export class ChainActionRefused extends Error {}
+
+export interface ChainVenue {
+ id: string
+ name: string
+ kinds: readonly ChainActionKind[]
+ prepare: (request: ChainActionRequest, account: string) => Promise
+ /**
+ * Refuses a payload that does anything but what was asked: wrong signer, an
+ * unexpected program, a transfer out. Runs on what the venue's API returned
+ * before the user sees it, and again on signed bytes at submit, because the
+ * venue's API and the browser are both outside what this server controls.
+ */
+ assertSafe: (payload: SigningPayload, account: string) => void
+ /** Overrides the module's submit where a venue lands its own transactions. */
+ submit?: ((input: SubmitInput) => Promise) | undefined
+}
+
+export interface ChainModule {
+ chain: ChainWithActions
+ tokens: readonly ChainToken[]
+ venues: readonly ChainVenue[]
+ defaultVenue: Partial>
+ /** Dollars per display unit, or nothing if no live price, in which case the trade is refused. */
+ priceUsd: (token: ChainToken) => Promise