From ac554382d77009d646833d8254cb1c43bea3a443 Mon Sep 17 00:00:00 2001
From: 0xDeon <110722148+0xDeon@users.noreply.github.com>
Date: Wed, 4 Nov 2026 02:40:52 +0100
Subject: [PATCH 1/6] feat(dapp): connect a wallet on every chain, behind a
wallet gate
Adds wallet connection on Stellar, Solana, Avalanche, Sui, Monad and NEAR,
a connect page in front of the app, the shared prepare, sign and submit
layer for chain actions, venue logos, native coin prices, and mainnet-only
production routing.
---
apps/dapp/.env.example | 18 +-
apps/dapp/app/[chain]/apps/page.tsx | 16 +-
apps/dapp/app/[chain]/intents/[id]/page.tsx | 8 +-
apps/dapp/app/[chain]/layout.tsx | 12 +-
apps/dapp/app/[chain]/loading.tsx | 10 +
.../app/api/chain/[chain]/prepare/route.ts | 69 +
.../app/api/chain/[chain]/submit/route.ts | 98 ++
apps/dapp/app/api/prices/native/route.ts | 14 +
apps/dapp/app/globals.css | 355 ++---
apps/dapp/app/layout.tsx | 86 +-
apps/dapp/app/page.tsx | 12 +
apps/dapp/components/apps/venue-card.tsx | 64 +-
apps/dapp/components/apps/venue-grid.tsx | 12 +-
.../components/connect/connect-dialog.tsx | 272 ++++
apps/dapp/components/connect/connect-home.tsx | 54 +
apps/dapp/components/connect/connect-page.tsx | 112 ++
.../components/connect/intent-showcase.tsx | 244 ++++
.../components/connect/showcase-states.tsx | 493 +++++++
apps/dapp/components/connect/wallet-gate.tsx | 79 +
.../components/intents/borrow-confirm.tsx | 11 +-
.../intents/chain-action-confirm.tsx | 115 ++
apps/dapp/components/intents/coin-stat.tsx | 30 +
apps/dapp/components/intents/intent-chat.tsx | 232 ++-
.../components/intents/native-coin-ticker.tsx | 46 +
apps/dapp/components/intents/price-ticker.tsx | 58 +-
apps/dapp/components/intents/swap-confirm.tsx | 6 +-
.../dapp/components/layout/chain-switcher.tsx | 125 +-
.../components/layout/chain-wallet-menu.tsx | 182 +++
.../dapp/components/layout/connect-wallet.tsx | 96 +-
.../dapp/components/layout/network-status.tsx | 2 +-
apps/dapp/components/layout/sidebar.tsx | 34 +-
.../components/layout/stellar-wallet-menu.tsx | 5 +-
apps/dapp/components/vault/balance-card.tsx | 7 +-
apps/dapp/e2e/chain-action.spec.ts | 202 +++
apps/dapp/e2e/chain-switcher.spec.ts | 37 +-
apps/dapp/e2e/connect-gate.spec.ts | 294 ++++
apps/dapp/e2e/home-redirect.spec.ts | 19 +-
apps/dapp/e2e/network-switch.spec.ts | 7 +-
apps/dapp/hooks/use-chain-action.ts | 151 ++
apps/dapp/hooks/use-go-to-chain.ts | 30 +
apps/dapp/lib/__tests__/chain-action.test.ts | 153 ++
.../dapp/lib/__tests__/chain-registry.test.ts | 17 +-
apps/dapp/lib/__tests__/chain-title.test.ts | 16 +
.../lib/__tests__/extension-console.test.ts | 65 +
.../lib/__tests__/middleware-network.test.ts | 27 +-
.../lib/__tests__/native-coin-price.test.ts | 95 ++
apps/dapp/lib/__tests__/nav-guard.test.ts | 36 +
apps/dapp/lib/__tests__/network-fetch.test.ts | 29 +-
apps/dapp/lib/__tests__/network-route.test.ts | 45 +-
.../dapp/lib/__tests__/network-select.test.ts | 23 +-
.../__tests__/other-stellar-network.test.ts | 18 +-
apps/dapp/lib/__tests__/requester.test.ts | 8 +-
.../lib/__tests__/venue-integration.test.ts | 11 +-
apps/dapp/lib/__tests__/venue-logos.test.ts | 43 +
.../__tests__/venue-mainnet-wording.test.ts | 41 +
.../dapp/lib/__tests__/wallet-connect.test.ts | 184 +++
apps/dapp/lib/adapters/arc-adapter.ts | 63 -
apps/dapp/lib/adapters/evm-adapter.ts | 133 ++
apps/dapp/lib/adapters/index.ts | 36 +
apps/dapp/lib/adapters/stellar-adapter.ts | 124 +-
apps/dapp/lib/adapters/store-adapter.ts | 64 +
apps/dapp/lib/api/network-fetch.ts | 11 +
apps/dapp/lib/chain-adapter.ts | 9 +
apps/dapp/lib/chain-mark.ts | 15 +
apps/dapp/lib/chain-title.ts | 4 +
apps/dapp/lib/chains/base64.ts | 13 +
apps/dapp/lib/chains/capabilities.ts | 14 +
apps/dapp/lib/chains/explain.ts | 22 +
apps/dapp/lib/chains/parse-action.ts | 70 +
apps/dapp/lib/chains/prepare.ts | 92 ++
apps/dapp/lib/chains/registry.ts | 16 +
apps/dapp/lib/chains/types.ts | 145 ++
apps/dapp/lib/extension-console.ts | 35 +
apps/dapp/lib/intent-intro-copy.ts | 6 +-
apps/dapp/lib/nav-guard.ts | 21 +
apps/dapp/lib/other-stellar-network.ts | 7 +-
apps/dapp/lib/prices/native-coin.ts | 66 +
apps/dapp/lib/server/analytics.ts | 18 +-
apps/dapp/lib/server/network-route.ts | 41 +-
apps/dapp/lib/server/requester.ts | 8 +-
apps/dapp/lib/venues.ts | 25 +
apps/dapp/lib/wagmi.config.ts | 26 +-
apps/dapp/lib/wallet/address.ts | 15 +
apps/dapp/lib/wallet/chain-cookie.ts | 12 +
apps/dapp/lib/wallet/chain-path.ts | 36 +
apps/dapp/lib/wallet/connector.ts | 33 +
apps/dapp/lib/wallet/connectors.ts | 50 +
apps/dapp/lib/wallet/evm-connector.ts | 20 +
apps/dapp/lib/wallet/evm-options.ts | 40 +
apps/dapp/lib/wallet/native-balance.ts | 66 +
apps/dapp/lib/wallet/near-connector.ts | 133 ++
apps/dapp/lib/wallet/standard-connector.ts | 182 +++
apps/dapp/lib/wallet/store-connector.ts | 12 +
apps/dapp/lib/wallet/wallet-store.ts | 74 +
apps/dapp/middleware.ts | 15 +-
apps/dapp/next.config.js | 3 +
apps/dapp/package.json | 8 +
apps/dapp/playwright.config.ts | 18 +
apps/dapp/providers/chain-provider.tsx | 10 +-
apps/dapp/providers/root.tsx | 30 +-
.../public/images/venues/1click-earn.webp | Bin 0 -> 1234 bytes
apps/dapp/public/images/venues/1click.webp | Bin 0 -> 1234 bytes
apps/dapp/public/images/venues/aftermath.webp | Bin 0 -> 7394 bytes
.../images/venues/bluefin-aggregator.webp | Bin 0 -> 4050 bytes
apps/dapp/public/images/venues/bucket.webp | Bin 0 -> 866 bytes
apps/dapp/public/images/venues/busha.webp | Bin 0 -> 1264 bytes
apps/dapp/public/images/venues/cetus.webp | Bin 0 -> 2386 bytes
.../public/images/venues/current-finance.webp | Bin 0 -> 1992 bytes
apps/dapp/public/images/venues/deepbook.webp | Bin 0 -> 984 bytes
apps/dapp/public/images/venues/ember.webp | Bin 0 -> 2674 bytes
apps/dapp/public/images/venues/haedal.webp | Bin 0 -> 1594 bytes
.../public/images/venues/jupiter-lend.webp | Bin 0 -> 2722 bytes
.../public/images/venues/jupiter-perps.webp | Bin 0 -> 2700 bytes
.../public/images/venues/jupiter-trigger.webp | Bin 0 -> 2688 bytes
apps/dapp/public/images/venues/jupiter.webp | Bin 0 -> 2700 bytes
apps/dapp/public/images/venues/kamino.webp | Bin 0 -> 560 bytes
.../public/images/venues/linq-solana.webp | Bin 0 -> 2436 bytes
apps/dapp/public/images/venues/linq-sui.webp | Bin 0 -> 2436 bytes
apps/dapp/public/images/venues/lulo.webp | Bin 0 -> 1108 bytes
apps/dapp/public/images/venues/metapool.webp | Bin 0 -> 1052 bytes
apps/dapp/public/images/venues/navi.webp | Bin 0 -> 2146 bytes
.../public/images/venues/near-agent-api.webp | Bin 0 -> 1234 bytes
.../images/venues/near-agent-market.webp | Bin 0 -> 1942 bytes
.../public/images/venues/near-ai-cloud.webp | Bin 0 -> 1680 bytes
.../images/venues/near-chain-signatures.webp | Bin 0 -> 1124 bytes
.../venues/near-confidential-swaps.webp | Bin 0 -> 1234 bytes
.../images/venues/near-hyperliquid.webp | Bin 0 -> 2286 bytes
.../images/venues/near-linq-cashout.webp | Bin 0 -> 2436 bytes
.../images/venues/near-trezu-sputnik.webp | Bin 0 -> 1098 bytes
apps/dapp/public/images/venues/onre.webp | Bin 0 -> 954 bytes
apps/dapp/public/images/venues/paycrest.webp | Bin 0 -> 4356 bytes
.../public/images/venues/payment-kit.webp | Bin 0 -> 1158 bytes
.../public/images/venues/phoenix-perps.webp | Bin 0 -> 3084 bytes
apps/dapp/public/images/venues/project-0.webp | Bin 0 -> 1562 bytes
apps/dapp/public/images/venues/reflect.webp | Bin 0 -> 944 bytes
apps/dapp/public/images/venues/rhea-dex.webp | Bin 0 -> 5842 bytes
apps/dapp/public/images/venues/rhea-lend.webp | Bin 0 -> 5842 bytes
apps/dapp/public/images/venues/rnear.webp | Bin 0 -> 2628 bytes
apps/dapp/public/images/venues/sanctum.webp | Bin 0 -> 2346 bytes
apps/dapp/public/images/venues/scallop.webp | Bin 0 -> 3250 bytes
apps/dapp/public/images/venues/springsui.webp | Bin 0 -> 2994 bytes
apps/dapp/public/images/venues/suilend.webp | Bin 0 -> 2268 bytes
packages/config/src/addresses.ts | 4 +-
packages/config/src/chain-route.ts | 13 +-
packages/config/src/chain.ts | 4 +-
packages/config/src/chains-registry.ts | 95 +-
packages/types/src/chain.ts | 22 +-
packages/types/src/venue.ts | 3 +
packages/ui/src/primitives/chain-logos.tsx | 45 +-
pnpm-lock.yaml | 1289 ++++++++++++++++-
150 files changed, 7097 insertions(+), 747 deletions(-)
create mode 100644 apps/dapp/app/[chain]/loading.tsx
create mode 100644 apps/dapp/app/api/chain/[chain]/prepare/route.ts
create mode 100644 apps/dapp/app/api/chain/[chain]/submit/route.ts
create mode 100644 apps/dapp/app/api/prices/native/route.ts
create mode 100644 apps/dapp/app/page.tsx
create mode 100644 apps/dapp/components/connect/connect-dialog.tsx
create mode 100644 apps/dapp/components/connect/connect-home.tsx
create mode 100644 apps/dapp/components/connect/connect-page.tsx
create mode 100644 apps/dapp/components/connect/intent-showcase.tsx
create mode 100644 apps/dapp/components/connect/showcase-states.tsx
create mode 100644 apps/dapp/components/connect/wallet-gate.tsx
create mode 100644 apps/dapp/components/intents/chain-action-confirm.tsx
create mode 100644 apps/dapp/components/intents/coin-stat.tsx
create mode 100644 apps/dapp/components/intents/native-coin-ticker.tsx
create mode 100644 apps/dapp/components/layout/chain-wallet-menu.tsx
create mode 100644 apps/dapp/e2e/chain-action.spec.ts
create mode 100644 apps/dapp/e2e/connect-gate.spec.ts
create mode 100644 apps/dapp/hooks/use-chain-action.ts
create mode 100644 apps/dapp/hooks/use-go-to-chain.ts
create mode 100644 apps/dapp/lib/__tests__/chain-action.test.ts
create mode 100644 apps/dapp/lib/__tests__/chain-title.test.ts
create mode 100644 apps/dapp/lib/__tests__/extension-console.test.ts
create mode 100644 apps/dapp/lib/__tests__/native-coin-price.test.ts
create mode 100644 apps/dapp/lib/__tests__/nav-guard.test.ts
create mode 100644 apps/dapp/lib/__tests__/venue-logos.test.ts
create mode 100644 apps/dapp/lib/__tests__/venue-mainnet-wording.test.ts
create mode 100644 apps/dapp/lib/__tests__/wallet-connect.test.ts
delete mode 100644 apps/dapp/lib/adapters/arc-adapter.ts
create mode 100644 apps/dapp/lib/adapters/evm-adapter.ts
create mode 100644 apps/dapp/lib/adapters/index.ts
create mode 100644 apps/dapp/lib/adapters/store-adapter.ts
create mode 100644 apps/dapp/lib/chain-mark.ts
create mode 100644 apps/dapp/lib/chain-title.ts
create mode 100644 apps/dapp/lib/chains/base64.ts
create mode 100644 apps/dapp/lib/chains/capabilities.ts
create mode 100644 apps/dapp/lib/chains/explain.ts
create mode 100644 apps/dapp/lib/chains/parse-action.ts
create mode 100644 apps/dapp/lib/chains/prepare.ts
create mode 100644 apps/dapp/lib/chains/registry.ts
create mode 100644 apps/dapp/lib/chains/types.ts
create mode 100644 apps/dapp/lib/extension-console.ts
create mode 100644 apps/dapp/lib/nav-guard.ts
create mode 100644 apps/dapp/lib/prices/native-coin.ts
create mode 100644 apps/dapp/lib/wallet/address.ts
create mode 100644 apps/dapp/lib/wallet/chain-cookie.ts
create mode 100644 apps/dapp/lib/wallet/chain-path.ts
create mode 100644 apps/dapp/lib/wallet/connector.ts
create mode 100644 apps/dapp/lib/wallet/connectors.ts
create mode 100644 apps/dapp/lib/wallet/evm-connector.ts
create mode 100644 apps/dapp/lib/wallet/evm-options.ts
create mode 100644 apps/dapp/lib/wallet/native-balance.ts
create mode 100644 apps/dapp/lib/wallet/near-connector.ts
create mode 100644 apps/dapp/lib/wallet/standard-connector.ts
create mode 100644 apps/dapp/lib/wallet/store-connector.ts
create mode 100644 apps/dapp/lib/wallet/wallet-store.ts
create mode 100644 apps/dapp/public/images/venues/1click-earn.webp
create mode 100644 apps/dapp/public/images/venues/1click.webp
create mode 100644 apps/dapp/public/images/venues/aftermath.webp
create mode 100644 apps/dapp/public/images/venues/bluefin-aggregator.webp
create mode 100644 apps/dapp/public/images/venues/bucket.webp
create mode 100644 apps/dapp/public/images/venues/busha.webp
create mode 100644 apps/dapp/public/images/venues/cetus.webp
create mode 100644 apps/dapp/public/images/venues/current-finance.webp
create mode 100644 apps/dapp/public/images/venues/deepbook.webp
create mode 100644 apps/dapp/public/images/venues/ember.webp
create mode 100644 apps/dapp/public/images/venues/haedal.webp
create mode 100644 apps/dapp/public/images/venues/jupiter-lend.webp
create mode 100644 apps/dapp/public/images/venues/jupiter-perps.webp
create mode 100644 apps/dapp/public/images/venues/jupiter-trigger.webp
create mode 100644 apps/dapp/public/images/venues/jupiter.webp
create mode 100644 apps/dapp/public/images/venues/kamino.webp
create mode 100644 apps/dapp/public/images/venues/linq-solana.webp
create mode 100644 apps/dapp/public/images/venues/linq-sui.webp
create mode 100644 apps/dapp/public/images/venues/lulo.webp
create mode 100644 apps/dapp/public/images/venues/metapool.webp
create mode 100644 apps/dapp/public/images/venues/navi.webp
create mode 100644 apps/dapp/public/images/venues/near-agent-api.webp
create mode 100644 apps/dapp/public/images/venues/near-agent-market.webp
create mode 100644 apps/dapp/public/images/venues/near-ai-cloud.webp
create mode 100644 apps/dapp/public/images/venues/near-chain-signatures.webp
create mode 100644 apps/dapp/public/images/venues/near-confidential-swaps.webp
create mode 100644 apps/dapp/public/images/venues/near-hyperliquid.webp
create mode 100644 apps/dapp/public/images/venues/near-linq-cashout.webp
create mode 100644 apps/dapp/public/images/venues/near-trezu-sputnik.webp
create mode 100644 apps/dapp/public/images/venues/onre.webp
create mode 100644 apps/dapp/public/images/venues/paycrest.webp
create mode 100644 apps/dapp/public/images/venues/payment-kit.webp
create mode 100644 apps/dapp/public/images/venues/phoenix-perps.webp
create mode 100644 apps/dapp/public/images/venues/project-0.webp
create mode 100644 apps/dapp/public/images/venues/reflect.webp
create mode 100644 apps/dapp/public/images/venues/rhea-dex.webp
create mode 100644 apps/dapp/public/images/venues/rhea-lend.webp
create mode 100644 apps/dapp/public/images/venues/rnear.webp
create mode 100644 apps/dapp/public/images/venues/sanctum.webp
create mode 100644 apps/dapp/public/images/venues/scallop.webp
create mode 100644 apps/dapp/public/images/venues/springsui.webp
create mode 100644 apps/dapp/public/images/venues/suilend.webp
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/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/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-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__/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__/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/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/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/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
+ /** Sends signed bytes, or records what a wallet already sent. */
+ submit: (input: SubmitInput) => Promise
+}
+
+export function tokenBySymbol(
+ tokens: readonly ChainToken[],
+ symbol: string
+): ChainToken | undefined {
+ const wanted = symbol.trim().toUpperCase()
+ return tokens.find((t) => t.symbol.toUpperCase() === wanted)
+}
+
+/** Base units for a decimal amount. Refuses more precision than the token has, rather than rounding. */
+export function toBaseUnits(amount: string, decimals: number): string {
+ const text = amount.trim()
+ if (!/^\d+(\.\d+)?$/.test(text)) throw new ChainActionRefused(`"${amount}" is not an amount.`)
+ const [whole = '0', fraction = ''] = text.split('.')
+ if (fraction.length > decimals) {
+ throw new ChainActionRefused(`This token has ${decimals} decimal places at most.`)
+ }
+ const digits = `${whole}${fraction.padEnd(decimals, '0')}`.replace(/^0+(?=\d)/, '')
+ if (/^0+$/.test(digits)) throw new ChainActionRefused('The amount must be more than zero.')
+ return digits
+}
diff --git a/apps/dapp/lib/extension-console.ts b/apps/dapp/lib/extension-console.ts
new file mode 100644
index 00000000..7e864343
--- /dev/null
+++ b/apps/dapp/lib/extension-console.ts
@@ -0,0 +1,35 @@
+const LOCATED_FRAME = /[a-z][\w+.-]*:\/\/|webpack-internal:/i
+const EXTENSION_FRAME = /(?:chrome|moz|safari-web)-extension:\/\//
+
+/** True only if the extension alone is on the stack, so the app had no part in the error. */
+export function isExtensionOnlyStack(stack: string | undefined): boolean {
+ if (stack === undefined) return false
+ const located = stack
+ .split('\n')
+ .filter((line) => line.trim().startsWith('at ') && LOCATED_FRAME.test(line))
+ return located.length > 0 && located.every((line) => EXTENSION_FRAME.test(line))
+}
+
+function callerStack(above: (...args: unknown[]) => void): string | undefined {
+ const holder: { stack?: string } = {}
+ Error.captureStackTrace?.(holder, above)
+ return holder.stack
+}
+
+/**
+ * Wallet extensions log their own failed requests with `console.error`, such as
+ * Slush whenever a connection is declined, and Next's dev overlay reports those as
+ * app errors. They go to `console.debug` instead, so they stay findable.
+ */
+export function quietExtensionErrors(
+ target: Pick,
+ stack: (above: (...args: unknown[]) => void) => string | undefined = callerStack
+): void {
+ const error = target.error.bind(target)
+ const debug = target.debug.bind(target)
+ const wrapper = (...args: unknown[]): void => {
+ if (isExtensionOnlyStack(stack(wrapper))) debug(...args)
+ else error(...args)
+ }
+ target.error = wrapper
+}
diff --git a/apps/dapp/lib/intent-intro-copy.ts b/apps/dapp/lib/intent-intro-copy.ts
index 1ca49744..4667ed60 100644
--- a/apps/dapp/lib/intent-intro-copy.ts
+++ b/apps/dapp/lib/intent-intro-copy.ts
@@ -1,7 +1,11 @@
+import { getChainDescriptor } from '@intent/config'
import type { ChainSlug } from '@intent/types'
/** The line under "Submit an intent", naming the chain the user will actually settle on. */
export function intentIntro(chain: ChainSlug): string {
- const settle = chain === 'arc' ? 'you settle in USDC on Arc' : 'you sign and settle on Stellar'
+ const settle =
+ chain === 'arc'
+ ? 'you settle in USDC on Arc'
+ : `you sign and settle on ${getChainDescriptor(chain).name}`
return `Declare the outcome you want. Autonomous agents compete to beat it — ${settle}.`
}
diff --git a/apps/dapp/lib/nav-guard.ts b/apps/dapp/lib/nav-guard.ts
new file mode 100644
index 00000000..7e582cfc
--- /dev/null
+++ b/apps/dapp/lib/nav-guard.ts
@@ -0,0 +1,21 @@
+/**
+ * A repeat click on a link already loading restarts the request from the back of
+ * the queue, so it arrives later the more it is clicked. Repeats are ignored,
+ * until `windowMs` has passed, so a navigation that never ends cannot kill a link.
+ */
+export function createNavGuard(
+ windowMs = 60_000,
+ now: () => number = Date.now
+): { follow: (href: string) => boolean; settle: () => void } {
+ let pending: { href: string; at: number } | undefined
+ return {
+ follow: (href) => {
+ if (pending?.href === href && now() - pending.at < windowMs) return false
+ pending = { href, at: now() }
+ return true
+ },
+ settle: () => {
+ pending = undefined
+ },
+ }
+}
diff --git a/apps/dapp/lib/other-stellar-network.ts b/apps/dapp/lib/other-stellar-network.ts
index baf23cb9..4f7c9738 100644
--- a/apps/dapp/lib/other-stellar-network.ts
+++ b/apps/dapp/lib/other-stellar-network.ts
@@ -14,7 +14,8 @@ export interface OtherStellarNetwork {
* lists, the sponsor and the session gate are all decided for that network.
* So the other network is a separate deployment, reached by a link. The
* address comes from configuration and is used only if it is http or https,
- * so a bad value cannot become a `javascript:` link in the menu.
+ * so a bad value cannot become a `javascript:` link in the menu. A mainnet
+ * deployment never links to testnet: people there should not see it exists.
*/
export function otherStellarNetwork(
active: StellarNetworkName,
@@ -23,6 +24,8 @@ export function otherStellarNetwork(
): OtherStellarNetwork {
const label = active === 'mainnet' ? 'Stellar testnet' : 'Stellar mainnet'
const base = otherUrl?.trim().replace(/\/+$/, '')
- if (base === undefined || !/^https?:\/\/[^/]/i.test(base)) return { label }
+ if (active === 'mainnet' || base === undefined || !/^https?:\/\/[^/]/i.test(base)) {
+ return { label }
+ }
return { label, href: `${base}${path}` }
}
diff --git a/apps/dapp/lib/prices/native-coin.ts b/apps/dapp/lib/prices/native-coin.ts
new file mode 100644
index 00000000..04fbabdb
--- /dev/null
+++ b/apps/dapp/lib/prices/native-coin.ts
@@ -0,0 +1,66 @@
+import { CHAIN_DESCRIPTORS } from '@intent/config'
+import type { ChainSlug } from '@intent/types'
+
+const PRICE_URL = 'https://coins.llama.fi/prices/current'
+const TIMEOUT_MS = 5_000
+const MIN_CONFIDENCE = 0.9
+const MAX_AGE_SECONDS = 300
+
+/** DefiLlama's id for each chain's own coin. */
+const COIN_IDS: Record = {
+ arc: 'usd-coin',
+ stellar: 'stellar',
+ solana: 'solana',
+ avalanche: 'avalanche-2',
+ sui: 'sui',
+ monad: 'monad',
+ near: 'near',
+}
+
+export interface NativeCoinPrice {
+ symbol: string
+ usd: number
+ asOf: string
+}
+
+interface LlamaCoin {
+ price?: unknown
+ symbol?: unknown
+ timestamp?: unknown
+ confidence?: unknown
+}
+
+/** What a chain's own coin is worth now, or nothing rather than a stale or doubtful figure. */
+export async function fetchNativeCoinPrice(
+ slug: ChainSlug,
+ fetcher: typeof fetch = fetch,
+ now: number = Date.now()
+): Promise {
+ const { symbol } = CHAIN_DESCRIPTORS[slug].nativeCurrency
+ const key = `coingecko:${COIN_IDS[slug]}`
+ 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 usd = typeof coin?.price === 'number' ? coin.price : NaN
+ const timestamp = Number(coin?.timestamp)
+ if (coin?.symbol !== symbol || !Number.isFinite(usd) || usd <= 0) return undefined
+ if (!(Number(coin?.confidence) >= MIN_CONFIDENCE)) return undefined
+ if (!Number.isFinite(timestamp) || now / 1000 - timestamp > MAX_AGE_SECONDS) return undefined
+ return { symbol, usd, asOf: new Date(timestamp * 1000).toISOString() }
+ } catch {
+ return undefined
+ }
+}
+
+/** `$93.32` from a dollar up, `$0.8982` below, where cents would hide the price. */
+export function formatCoinUsd(usd: number): string {
+ const places = usd >= 1 ? 2 : 4
+ return `$${usd.toLocaleString('en-US', {
+ minimumFractionDigits: places,
+ maximumFractionDigits: places,
+ })}`
+}
diff --git a/apps/dapp/lib/server/analytics.ts b/apps/dapp/lib/server/analytics.ts
index 0eb434f1..4f6ed76a 100644
--- a/apps/dapp/lib/server/analytics.ts
+++ b/apps/dapp/lib/server/analytics.ts
@@ -97,7 +97,19 @@ export const ANALYTICS_DDL: readonly string[] = [
`ALTER TABLE agent_proposals ADD COLUMN IF NOT EXISTS reasoning TEXT`,
]
-export type ExecutionKind = 'swap' | 'plan' | 'offer' | 'send' | 'lend' | 'offramp' | 'perp'
+export type ExecutionKind =
+ | 'swap'
+ | 'plan'
+ | 'offer'
+ | 'send'
+ | 'lend'
+ | 'offramp'
+ | 'perp'
+ // Actions on other chains, by chain: the row's network column is a Stellar
+ // network, so the kind is what keeps these apart from Stellar's numbers.
+ | 'solana'
+ | 'sui'
+ | 'near'
export interface ExecutionRecord {
network: StellarNetworkName
@@ -507,6 +519,8 @@ export interface LogExecutionInput {
result: SubmitLike
/** The signed envelope that was sent, so what it moved can be recorded. */
signedXdr?: string
+ /** Set for actions on other chains, which only run on mainnet. Defaults to the page's Stellar network. */
+ network?: StellarNetworkName
}
export const RACE_HEADER = 'x-intent-race'
@@ -546,7 +560,7 @@ export async function logExecution(input: LogExecutionInput): Promise {
const flow = await readFlowSafely(input)
const raceId = await raceFromRequest()
await repo.recordExecution({
- network: activeNetwork(),
+ network: input.network ?? activeNetwork(),
account: input.account,
kind: input.kind,
feeSponsored: input.feeSponsored,
diff --git a/apps/dapp/lib/server/network-route.ts b/apps/dapp/lib/server/network-route.ts
index 8dee6372..427f76da 100644
--- a/apps/dapp/lib/server/network-route.ts
+++ b/apps/dapp/lib/server/network-route.ts
@@ -1,4 +1,4 @@
-import type { StellarNetworkName } from '@intent/config'
+import { homeSegment, parseChainSegment, type StellarNetworkName } from '@intent/config'
/**
* Reading a network out of an address, for middleware. Pure and free of Node
@@ -41,17 +41,46 @@ const BARE_SCREENS = [
]
/**
- * Where the bare root, and the old unprefixed screens, go: the same screen on the
- * home chain, keeping the rest of the path and the query. Nothing for any other
- * address.
+ * Where the bare root, and the old unprefixed screens, go. The root is the connect
+ * page, so it stays put unless a chain is remembered from an earlier visit. The old
+ * screens open on the home chain, keeping the rest of the path and the query.
+ * Nothing for any other address.
*/
-export function bareRedirect(pathname: string, search: string, home: string): string | undefined {
- if (pathname === '/') return `/${home}/intents${search}`
+export function bareRedirect(
+ pathname: string,
+ search: string,
+ home: string,
+ remembered: string | undefined
+): string | undefined {
+ if (pathname === '/') {
+ return remembered === undefined ? undefined : `/${remembered}/intents${search}`
+ }
const first = pathname.split('/')[1] ?? ''
if (!BARE_SCREENS.includes(first)) return undefined
return `/${home}${pathname}${search}`
}
+/**
+ * The chain last connected to, so a returning visitor goes back to their wallet.
+ * It comes from a cookie anyone can edit, so it counts only if it names a real chain.
+ */
+export function rememberedChain(
+ remembered: string | undefined,
+ enabled: readonly StellarNetworkName[]
+): string | undefined {
+ return remembered !== undefined && parseChainSegment(remembered, enabled) !== undefined
+ ? remembered
+ : undefined
+}
+
+/** The chain an old unprefixed screen opens on. */
+export function homeFor(
+ remembered: string | undefined,
+ enabled: readonly StellarNetworkName[]
+): string {
+ return rememberedChain(remembered, enabled) ?? homeSegment(enabled)
+}
+
/** Pages that are not part of the app's chain screens and are never behind the gate. */
const GATE_FREE = ['/verify', '/waitlist', '/admin']
diff --git a/apps/dapp/lib/server/requester.ts b/apps/dapp/lib/server/requester.ts
index 8ed87e97..d2ee0912 100644
--- a/apps/dapp/lib/server/requester.ts
+++ b/apps/dapp/lib/server/requester.ts
@@ -1,14 +1,12 @@
+import { isWalletAddress } from '../wallet/address'
import { WALLET_HEADER } from '../wallet-header'
import { readSessionFromRequest } from './session'
-/** The shape of a Stellar account id. Only a record key here, so the checksum is not worth the SDK's load time. */
-const ACCOUNT_ID = /^G[A-Z2-7]{55}$/
-
/**
* Who a request is for, where the app keeps a person's records.
*
* The signed-in email when there is one. With the email gate paused nobody
- * signs in, so the connected wallet stands in as `wallet:G…`. The header is the
+ * signs in, so the connected wallet stands in as `wallet:`. The header is the
* page's own claim and proves nothing about who holds the wallet: it keeps
* one wallet's records apart from another's for honest use, and money still
* moves only by a signature from the wallet itself.
@@ -17,7 +15,7 @@ export function requesterOf(request: Request): { email: string } | undefined {
const session = readSessionFromRequest(request)
if (session !== undefined) return { email: session.email }
const wallet = request.headers.get(WALLET_HEADER)?.trim()
- if (wallet !== undefined && ACCOUNT_ID.test(wallet)) {
+ if (wallet !== undefined && isWalletAddress(wallet)) {
return { email: `wallet:${wallet}` }
}
return undefined
diff --git a/apps/dapp/lib/venues.ts b/apps/dapp/lib/venues.ts
index aa528a3f..6bba43be 100644
--- a/apps/dapp/lib/venues.ts
+++ b/apps/dapp/lib/venues.ts
@@ -1,9 +1,15 @@
import { activeNetwork, type StellarNetworkName } from '@intent/config'
import type { Venue } from '@intent/types'
+import { nearVenues } from './chains/near/venues'
+import { solanaVenues } from './chains/solana/venues'
+import { suiVenues } from './chains/sui/venues'
import { anchorsOn } from './offramp/anchors'
export const venues: Venue[] = [
+ ...solanaVenues,
+ ...suiVenues,
+ ...nearVenues,
{
id: 'cow-swap',
family: 'evm' as const,
@@ -362,6 +368,25 @@ export function notOnNetworkLabel(
return onNetwork ? undefined : 'Not on mainnet yet'
}
+/** The test anchor exists only for testnet. On mainnet it is not coming, so it is not listed. */
+export function isListedOn(
+ venue: Pick,
+ network: StellarNetworkName = activeNetwork()
+): boolean {
+ return !(venue.id === 'testanchor' && network === 'mainnet')
+}
+
+/**
+ * What a venue's card says it does. A capability describes the network it works
+ * on, so a venue not on this one gets only its summary.
+ */
+export function venueDetail(
+ venue: Pick,
+ notHere: string | undefined
+): string {
+ return notHere === undefined ? (venue.capability ?? venue.bestFor) : venue.bestFor
+}
+
/**
* Which venues the Apps page may call available here — decided on the
* server, because MoneyGram's mainnet presence depends on configuration the
diff --git a/apps/dapp/lib/wagmi.config.ts b/apps/dapp/lib/wagmi.config.ts
index 1c0515c8..df43aab1 100644
--- a/apps/dapp/lib/wagmi.config.ts
+++ b/apps/dapp/lib/wagmi.config.ts
@@ -1,13 +1,9 @@
import { connectorsForWallets } from '@rainbow-me/rainbowkit'
-import {
- injectedWallet,
- metaMaskWallet,
- rainbowWallet,
- walletConnectWallet,
-} from '@rainbow-me/rainbowkit/wallets'
+import { injectedWallet, walletConnectWallet } from '@rainbow-me/rainbowkit/wallets'
import { arcTestnet as arcTestnetConfig } from '@intent/config'
import { createConfig, http } from 'wagmi'
import { defineChain } from 'viem'
+import { avalanche, monad } from 'viem/chains'
/**
* The viem chain, derived from the shared config rather than redeclared, so
@@ -39,20 +35,16 @@ const hasProjectId = projectId !== '' && projectId !== 'your-project-id'
* WalletConnect id requires an external signup at cloud.reown.com, and without
* this the app cannot even build for anyone who has not done it.
*
- * The subtlety that matters: `metaMaskWallet` decides between the extension and
- * WalletConnect via `!isMetaMaskInjected && !isMobile()`, evaluated at build
- * time. During SSR prerender there is no `window`, so it always chooses
- * WalletConnect and throws. `injectedWallet` is the only one that never reaches
- * `getWalletConnectConnector`, so it is the only safe entry without an id.
+ * Browser wallets are discovered by name through EIP-6963, so only the generic
+ * injected wallet and WalletConnect are declared. WalletConnect is the way in
+ * for mobile wallets, and shows its own QR code.
*/
-const wallets = hasProjectId
- ? [injectedWallet, metaMaskWallet, rainbowWallet, walletConnectWallet]
- : [injectedWallet]
+const wallets = hasProjectId ? [injectedWallet, walletConnectWallet] : [injectedWallet]
if (!hasProjectId && typeof window !== 'undefined') {
console.warn(
'[wallet] NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is unset. ' +
- 'Browser-extension wallets work; WalletConnect, Rainbow and mobile wallets are unavailable. ' +
+ 'Browser-extension wallets work; WalletConnect and mobile wallets are unavailable. ' +
'Get an id at https://cloud.reown.com'
)
}
@@ -64,9 +56,11 @@ const connectors = connectorsForWallets([{ groupName: 'Wallets', wallets }], {
export const wagmiConfig = createConfig({
connectors,
- chains: [arcTestnet],
+ chains: [arcTestnet, avalanche, monad],
transports: {
[arcTestnet.id]: http(arcTestnetConfig.rpcUrl),
+ [avalanche.id]: http(),
+ [monad.id]: http(),
},
ssr: true,
})
diff --git a/apps/dapp/lib/wallet/address.ts b/apps/dapp/lib/wallet/address.ts
new file mode 100644
index 00000000..22e07289
--- /dev/null
+++ b/apps/dapp/lib/wallet/address.ts
@@ -0,0 +1,15 @@
+const STELLAR = /^G[A-Z2-7]{55}$/
+const EVM = /^0x[0-9a-fA-F]{40}$/
+const SUI = /^0x[0-9a-fA-F]{64}$/
+const SOLANA = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
+const NEAR_IMPLICIT = /^[0-9a-f]{64}$/
+/** Named NEAR accounts always have a dot once they are under a top-level account like `.near`. */
+const NEAR_NAMED = /^(?:[a-z0-9]+(?:[-_][a-z0-9]+)*\.)+[a-z0-9]+(?:[-_][a-z0-9]+)*$/
+
+/**
+ * Whether a value has the shape of an address on a chain the app connects to.
+ * Only a record key where it is used, so shape is enough and no checksum is read.
+ */
+export function isWalletAddress(value: string): boolean {
+ return [STELLAR, EVM, SUI, SOLANA, NEAR_IMPLICIT, NEAR_NAMED].some((shape) => shape.test(value))
+}
diff --git a/apps/dapp/lib/wallet/chain-cookie.ts b/apps/dapp/lib/wallet/chain-cookie.ts
new file mode 100644
index 00000000..39316a16
--- /dev/null
+++ b/apps/dapp/lib/wallet/chain-cookie.ts
@@ -0,0 +1,12 @@
+/** Holds the chain last connected to, so the bare root can open on it. Read by middleware. */
+export const CHAIN_COOKIE = 'intent-chain'
+
+const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365
+
+export function rememberChain(segment: string): void {
+ document.cookie = `${CHAIN_COOKIE}=${segment}; path=/; max-age=${ONE_YEAR_SECONDS}; samesite=lax`
+}
+
+export function forgetChain(): void {
+ document.cookie = `${CHAIN_COOKIE}=; path=/; max-age=0; samesite=lax`
+}
diff --git a/apps/dapp/lib/wallet/chain-path.ts b/apps/dapp/lib/wallet/chain-path.ts
new file mode 100644
index 00000000..48591040
--- /dev/null
+++ b/apps/dapp/lib/wallet/chain-path.ts
@@ -0,0 +1,36 @@
+/** The same screen under another chain's segment. Everything deeper is chain-independent. */
+export function pathUnderChain(pathname: string, segment: string): string {
+ const rest = pathname.split('/').slice(2).join('/')
+ return `/${segment}${rest === '' ? '/intents' : `/${rest}`}`
+}
+
+const DEFAULT_SCREEN = '/intents'
+
+/**
+ * The connect page's address for someone who asked for a screen on a chain and
+ * has no wallet there. Just the bare address when they asked for the default one.
+ */
+export function connectAddress(pathname: string, search: string): string {
+ const rest = pathname.split('/').slice(2).join('/')
+ const screen = `/${rest}${search}`
+ if (screen === '/' || screen === DEFAULT_SCREEN) return '/'
+ return `/?next=${encodeURIComponent(screen)}`
+}
+
+/**
+ * The screen to open once a chain is picked, from the connect page's `next`.
+ *
+ * It comes from the address, so anyone can write it. It is only ever put after a
+ * chain segment, and is refused unless it is plainly a path on this site.
+ */
+export function screenAfterConnect(next: string | null): string {
+ if (next === null || !next.startsWith('/') || next.startsWith('//')) return DEFAULT_SCREEN
+ if (next.includes('\\') || next.includes('..')) return DEFAULT_SCREEN
+ try {
+ const url = new URL(next, 'https://intent.invalid')
+ if (url.origin !== 'https://intent.invalid' || url.pathname === '/') return DEFAULT_SCREEN
+ return `${url.pathname}${url.search}`
+ } catch {
+ return DEFAULT_SCREEN
+ }
+}
diff --git a/apps/dapp/lib/wallet/connector.ts b/apps/dapp/lib/wallet/connector.ts
new file mode 100644
index 00000000..cc5c708b
--- /dev/null
+++ b/apps/dapp/lib/wallet/connector.ts
@@ -0,0 +1,33 @@
+/** One wallet in the connect dialog's list. */
+export interface WalletOption {
+ id: string
+ name: string
+ icon?: string | undefined
+ /** Ready to connect now. When false, `installUrl` says where to get it. */
+ detected: boolean
+ installUrl?: string | undefined
+}
+
+/**
+ * What the connect dialog needs from a chain, and nothing else.
+ *
+ * The dialog draws its own wallet list on every chain, so each library is used
+ * only for its data and its connect call, never for its modal.
+ */
+export interface WalletConnector {
+ listWallets: () => Promise
+ /** Prompts the wallet. Throws if the wallet refuses or the user declines. */
+ connect: (walletId: string) => Promise
+ /** Whether this chain already has a connected wallet. */
+ isConnected: () => boolean
+}
+
+/** Wallets ready to connect first, keeping each library's own order within a group. */
+export function orderWallets(options: readonly WalletOption[]): WalletOption[] {
+ return [...options.filter((o) => o.detected), ...options.filter((o) => !o.detected)]
+}
+
+/** Wallets word a decline differently, and a decline is not a failure to report. */
+export function isUserRejection(message: string): boolean {
+ return /reject|denied|declin|cancel|close/i.test(message)
+}
diff --git a/apps/dapp/lib/wallet/connectors.ts b/apps/dapp/lib/wallet/connectors.ts
new file mode 100644
index 00000000..fc5d1edb
--- /dev/null
+++ b/apps/dapp/lib/wallet/connectors.ts
@@ -0,0 +1,50 @@
+'use client'
+
+import type { ChainSlug } from '@intent/types'
+import { avalanche, monad } from 'viem/chains'
+
+import { stellarConnector } from '../adapters/stellar-adapter'
+import { arcTestnet } from '../wagmi.config'
+import { signSolana } from '../chains/solana/sign'
+import { signSui } from '../chains/sui/sign'
+import type { WalletConnector } from './connector'
+import { createEvmConnector } from './evm-connector'
+import { nearConnector } from './near-connector'
+import { createStandardConnector } from './standard-connector'
+
+export const solanaConnector = createStandardConnector({
+ storageKey: 'intent.wallet.solana',
+ chain: 'solana:mainnet',
+ sign: signSolana,
+ known: [
+ { name: 'Phantom', installUrl: 'https://phantom.com/download' },
+ { name: 'Solflare', installUrl: 'https://solflare.com' },
+ { name: 'Backpack', installUrl: 'https://backpack.app' },
+ ],
+})
+
+export const suiConnector = createStandardConnector({
+ storageKey: 'intent.wallet.sui',
+ chain: 'sui:mainnet',
+ sign: signSui,
+ known: [
+ { name: 'Slush', installUrl: 'https://slush.app' },
+ { name: 'Suiet', installUrl: 'https://suiet.app' },
+ ],
+})
+
+export { nearConnector }
+
+const CONNECTORS: Record = {
+ stellar: stellarConnector,
+ solana: solanaConnector,
+ sui: suiConnector,
+ near: nearConnector,
+ arc: createEvmConnector(arcTestnet.id),
+ avalanche: createEvmConnector(avalanche.id),
+ monad: createEvmConnector(monad.id),
+}
+
+export function connectorFor(slug: ChainSlug): WalletConnector {
+ return CONNECTORS[slug]
+}
diff --git a/apps/dapp/lib/wallet/evm-connector.ts b/apps/dapp/lib/wallet/evm-connector.ts
new file mode 100644
index 00000000..0ca4686f
--- /dev/null
+++ b/apps/dapp/lib/wallet/evm-connector.ts
@@ -0,0 +1,20 @@
+import { connect, getAccount, getConnectors, switchChain } from 'wagmi/actions'
+
+import { wagmiConfig } from '../wagmi.config'
+import type { WalletConnector } from './connector'
+import { evmWalletOptions } from './evm-options'
+
+/** An EVM chain's connector. wagmi holds the connection, shared by every EVM chain. */
+export function createEvmConnector(chainId: number): WalletConnector {
+ return {
+ isConnected: () => getAccount(wagmiConfig).isConnected,
+ listWallets: async () => evmWalletOptions(getConnectors(wagmiConfig)),
+ connect: async (walletId) => {
+ const connector = getConnectors(wagmiConfig).find((c) => c.id === walletId)
+ if (connector === undefined) throw new Error('That wallet is not available in this browser.')
+ await connect(wagmiConfig, { connector })
+ // Asked for, not required: a wallet that refuses is shown the switch prompt in the app.
+ await switchChain(wagmiConfig, { chainId }).catch(() => undefined)
+ },
+ }
+}
diff --git a/apps/dapp/lib/wallet/evm-options.ts b/apps/dapp/lib/wallet/evm-options.ts
new file mode 100644
index 00000000..bf74263e
--- /dev/null
+++ b/apps/dapp/lib/wallet/evm-options.ts
@@ -0,0 +1,40 @@
+import { orderWallets, type WalletOption } from './connector'
+
+export interface EvmConnectorInfo {
+ id: string
+ name: string
+ type: string
+ icon?: string | undefined
+}
+
+/** Offered with an install link when the browser has no such wallet. */
+const KNOWN_EVM_WALLETS = [
+ { name: 'MetaMask', installUrl: 'https://metamask.io/download' },
+ { name: 'Rabby Wallet', installUrl: 'https://rabby.io' },
+]
+
+/**
+ * The wallet list for an EVM chain.
+ *
+ * wagmi reports each browser wallet by name (EIP-6963) and also keeps a generic
+ * "injected" connector. The generic one is only useful when nothing named turned
+ * up, and otherwise is the same wallet listed twice.
+ */
+export function evmWalletOptions(connectors: readonly EvmConnectorInfo[]): WalletOption[] {
+ const named = connectors.filter((c) => c.id !== 'injected')
+ const anyBrowserWallet = named.some((c) => c.type === 'injected')
+ const usable = anyBrowserWallet ? named : connectors
+
+ const seen = new Set()
+ const options: WalletOption[] = []
+ for (const c of usable) {
+ if (seen.has(c.name)) continue
+ seen.add(c.name)
+ options.push({ id: c.id, name: c.name, icon: c.icon, detected: true })
+ }
+ for (const k of KNOWN_EVM_WALLETS) {
+ if (seen.has(k.name)) continue
+ options.push({ id: k.name, name: k.name, detected: false, installUrl: k.installUrl })
+ }
+ return orderWallets(options)
+}
diff --git a/apps/dapp/lib/wallet/native-balance.ts b/apps/dapp/lib/wallet/native-balance.ts
new file mode 100644
index 00000000..76fcedb0
--- /dev/null
+++ b/apps/dapp/lib/wallet/native-balance.ts
@@ -0,0 +1,66 @@
+import type { ChainSlug } from '@intent/types'
+import { formatUnits } from 'viem'
+
+type ReadableChain = Extract
+
+async function post(url: string, body: unknown): Promise {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) throw new Error(`Balance read failed (${res.status})`)
+ return res.json()
+}
+
+interface JsonRpcResult {
+ result?: T
+}
+
+/** Lamports, from a public RPC that allows browser calls. */
+async function solanaRaw(address: string): Promise {
+ const body = (await post('https://solana-rpc.publicnode.com', {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'getBalance',
+ params: [address],
+ })) as JsonRpcResult<{ value: number }>
+ if (body.result === undefined) throw new Error('No balance returned')
+ return String(body.result.value)
+}
+
+/** MIST, from Sui's GraphQL service: public fullnodes no longer answer JSON-RPC. */
+async function suiRaw(address: string): Promise {
+ const body = (await post('https://graphql.mainnet.sui.io/graphql', {
+ query:
+ 'query($a:SuiAddress!){address(address:$a){balance(coinType:"0x2::sui::SUI"){totalBalance}}}',
+ variables: { a: address },
+ })) as { data?: { address?: { balance?: { totalBalance?: string } | null } | null } }
+ return body.data?.address?.balance?.totalBalance ?? '0'
+}
+
+/** yoctoNEAR. An account that does not exist yet comes back as an error, which is a balance of zero. */
+async function nearRaw(address: string): Promise {
+ const body = (await post('https://free.rpc.fastnear.com', {
+ jsonrpc: '2.0',
+ id: '1',
+ method: 'query',
+ params: { request_type: 'view_account', finality: 'final', account_id: address },
+ })) as JsonRpcResult<{ amount: string }>
+ return body.result?.amount ?? '0'
+}
+
+const READERS: Record Promise> = {
+ solana: solanaRaw,
+ sui: suiRaw,
+ near: nearRaw,
+}
+
+/** The chain's native balance as a decimal string. */
+export async function fetchNativeBalance(
+ chain: ReadableChain,
+ address: string,
+ decimals: number
+): Promise {
+ return formatUnits(BigInt(await READERS[chain](address)), decimals)
+}
diff --git a/apps/dapp/lib/wallet/near-connector.ts b/apps/dapp/lib/wallet/near-connector.ts
new file mode 100644
index 00000000..2eab55ac
--- /dev/null
+++ b/apps/dapp/lib/wallet/near-connector.ts
@@ -0,0 +1,133 @@
+import type { NearConnector } from '@hot-labs/near-connect'
+
+import { isUserRejection, orderWallets } from './connector'
+import { walletErrorMessage } from './error-message'
+import type { StoreConnector } from './store-connector'
+import { createWalletStore } from './wallet-store'
+
+const store = createWalletStore('intent.wallet.near')
+
+let instance: Promise | undefined
+let restoring: Promise | undefined
+
+/** Loaded on first use: the connector touches the browser, and most visitors never pick NEAR. */
+function connector(): Promise {
+ instance ??= import('@hot-labs/near-connect').then(({ NearConnector: Connector }) => {
+ const built = new Connector({
+ network: 'mainnet',
+ footerBranding: null,
+ // A command-line tool, not something a person connects from a web page.
+ excludedWallets: ['near-cli'],
+ })
+ built.on('wallet:signOut', () => {
+ store.remember(undefined)
+ store.set({
+ address: undefined,
+ walletName: undefined,
+ walletIcon: undefined,
+ isRestoring: false,
+ })
+ })
+ return built
+ })
+ return instance
+}
+
+/**
+ * NEAR wallets through NEAR Connect. Its wallet list comes from a manifest it
+ * fetches, so a failed fetch reads as an empty list rather than an exception.
+ */
+export const nearConnector: StoreConnector = {
+ store,
+ isConnected: () => store.get().address !== undefined,
+ listWallets: async () => {
+ const near = await connector()
+ await near.whenManifestLoaded
+ return orderWallets(
+ near.availableWallets.map(({ manifest }) => ({
+ id: manifest.id,
+ name: manifest.name,
+ icon: manifest.icon,
+ // Each one runs in a sandbox the connector hosts, so there is nothing to install first.
+ detected: true,
+ }))
+ )
+ },
+ connect: async (walletId) => {
+ const near = await connector()
+ const wallet = await near.connect({ walletId })
+ const [account] = await wallet.getAccounts({ network: 'mainnet' })
+ if (account === undefined) {
+ throw new Error(`${wallet.manifest.name} has no NEAR mainnet account. Add one and try again.`)
+ }
+ store.remember(walletId)
+ store.set({
+ address: account.accountId,
+ walletName: wallet.manifest.name,
+ walletIcon: wallet.manifest.icon,
+ isRestoring: false,
+ })
+ },
+ restore: () => {
+ restoring ??= (async () => {
+ if (store.remembered() === undefined || store.get().address !== undefined) {
+ store.set({ isRestoring: false })
+ return
+ }
+ try {
+ const near = await connector()
+ const { wallet, accounts } = await near.getConnectedWallet()
+ const [account] = accounts
+ if (account === undefined) throw new Error('no account')
+ store.set({
+ address: account.accountId,
+ walletName: wallet.manifest.name,
+ walletIcon: wallet.manifest.icon,
+ isRestoring: false,
+ })
+ } catch {
+ store.remember(undefined)
+ store.set({ isRestoring: false })
+ }
+ })()
+ return restoring
+ },
+ signPrepared: async (payload) => {
+ if (payload.family !== 'near') {
+ return { ok: false, reason: 'wallet_error', detail: 'That is not a NEAR transaction.' }
+ }
+ const signerId = store.get().address
+ if (signerId === undefined) {
+ return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' }
+ }
+ try {
+ const near = await connector()
+ const wallet = await near.wallet()
+ const outcomes = await wallet.signAndSendTransactions({
+ network: 'mainnet',
+ signerId,
+ transactions: payload.transactions,
+ })
+ return {
+ ok: true,
+ signed: { family: 'near', hashes: outcomes.map((o) => o.transaction.hash as string) },
+ }
+ } catch (e) {
+ const detail = walletErrorMessage(e)
+ return isUserRejection(detail)
+ ? { ok: false, reason: 'rejected' }
+ : { ok: false, reason: 'wallet_error', detail }
+ }
+ },
+ disconnect: async () => {
+ const near = await connector()
+ store.remember(undefined)
+ store.set({
+ address: undefined,
+ walletName: undefined,
+ walletIcon: undefined,
+ isRestoring: false,
+ })
+ await near.disconnect().catch(() => undefined)
+ },
+}
diff --git a/apps/dapp/lib/wallet/standard-connector.ts b/apps/dapp/lib/wallet/standard-connector.ts
new file mode 100644
index 00000000..667c2a14
--- /dev/null
+++ b/apps/dapp/lib/wallet/standard-connector.ts
@@ -0,0 +1,182 @@
+import { getWallets } from '@wallet-standard/app'
+import type { Wallet, WalletAccount } from '@wallet-standard/base'
+import {
+ StandardConnect,
+ StandardDisconnect,
+ StandardEvents,
+ type StandardConnectFeature,
+ type StandardDisconnectFeature,
+ type StandardEventsFeature,
+} from '@wallet-standard/features'
+
+import type { SignedOutcome, SignedPayload, SigningPayload } from '../chains/types'
+import { isUserRejection, orderWallets, type WalletOption } from './connector'
+import { walletErrorMessage } from './error-message'
+import type { StoreConnector } from './store-connector'
+import { createWalletStore } from './wallet-store'
+
+/** How long a remembered extension gets to announce itself after a reload. */
+const REGISTER_WAIT_MS = 1_500
+
+interface KnownWallet {
+ name: string
+ installUrl: string
+}
+
+/**
+ * Solana and Sui wallets both announce themselves through the Wallet Standard,
+ * so one connector serves both, told apart by the chain each wallet declares.
+ */
+export function createStandardConnector({
+ storageKey,
+ chain,
+ known,
+ sign,
+}: {
+ storageKey: string
+ /** The Wallet Standard chain id, such as `solana:mainnet`. */
+ chain: `${string}:${string}`
+ /** Shown with an install link when not detected. */
+ known: readonly KnownWallet[]
+ /** Calls the wallet's signing feature, which differs by chain. Throws if it cannot. */
+ sign: (wallet: Wallet, account: WalletAccount, payload: SigningPayload) => Promise
+}): StoreConnector {
+ const store = createWalletStore(storageKey)
+ let stopWatching: (() => void) | undefined
+ let restoring: Promise | undefined
+
+ function installed(): Wallet[] {
+ return getWallets()
+ .get()
+ .filter((w) => w.chains.includes(chain) && StandardConnect in w.features)
+ }
+
+ function accountOn(accounts: readonly WalletAccount[]): WalletAccount | undefined {
+ return accounts.find((a) => a.chains.includes(chain)) ?? accounts[0]
+ }
+
+ function adopt(wallet: Wallet, account: WalletAccount): void {
+ store.set({
+ address: account.address,
+ walletName: wallet.name,
+ walletIcon: wallet.icon,
+ isRestoring: false,
+ })
+ store.remember(wallet.name)
+ stopWatching?.()
+ const events = (wallet.features as Partial)[StandardEvents]
+ // Switching account inside the wallet must not leave the app on the old one.
+ stopWatching = events?.on('change', ({ accounts }) => {
+ if (accounts === undefined) return
+ const next = accountOn(accounts)
+ if (next === undefined) forget()
+ else store.set({ address: next.address })
+ })
+ }
+
+ function forget(): void {
+ stopWatching?.()
+ stopWatching = undefined
+ store.remember(undefined)
+ store.set({
+ address: undefined,
+ walletName: undefined,
+ walletIcon: undefined,
+ isRestoring: false,
+ })
+ }
+
+ async function connectWith(wallet: Wallet, silent: boolean): Promise {
+ const feature = (wallet.features as StandardConnectFeature)[StandardConnect]
+ const { accounts } = await feature.connect({ silent })
+ const account = accountOn(accounts)
+ if (account === undefined) {
+ throw new Error(`${wallet.name} has no account on this chain. Add one and try again.`)
+ }
+ adopt(wallet, account)
+ }
+
+ /** An extension can announce itself after the page loads, so a remembered one gets a moment. */
+ async function waitFor(name: string): Promise {
+ const present = installed().find((w) => w.name === name)
+ if (present !== undefined) return present
+ return new Promise((resolve) => {
+ const off = getWallets().on('register', () => {
+ const found = installed().find((w) => w.name === name)
+ if (found === undefined) return
+ off()
+ window.clearTimeout(timer)
+ resolve(found)
+ })
+ const timer = window.setTimeout(() => {
+ off()
+ resolve(undefined)
+ }, REGISTER_WAIT_MS)
+ })
+ }
+
+ return {
+ store,
+ isConnected: () => store.get().address !== undefined,
+ listWallets: async () => {
+ const present = installed()
+ const options: WalletOption[] = present.map((w) => ({
+ id: w.name,
+ name: w.name,
+ icon: w.icon,
+ detected: true,
+ }))
+ for (const k of known) {
+ if (present.some((w) => w.name === k.name)) continue
+ options.push({ id: k.name, name: k.name, detected: false, installUrl: k.installUrl })
+ }
+ return orderWallets(options)
+ },
+ connect: async (walletId) => {
+ const wallet = installed().find((w) => w.name === walletId)
+ if (wallet === undefined) throw new Error(`${walletId} is not installed in this browser.`)
+ await connectWith(wallet, false)
+ },
+ restore: () => {
+ restoring ??= (async () => {
+ const name = store.remembered()
+ if (name === undefined || store.get().address !== undefined) {
+ store.set({ isRestoring: false })
+ return
+ }
+ try {
+ const wallet = await waitFor(name)
+ if (wallet === undefined) throw new Error('not present')
+ await connectWith(wallet, true)
+ } catch {
+ // Locked, removed or no longer authorised: back to the connect page.
+ forget()
+ }
+ })()
+ return restoring
+ },
+ signPrepared: async (payload): Promise => {
+ const wallet = installed().find((w) => w.name === store.get().walletName)
+ const account = wallet?.accounts.find((a) => a.address === store.get().address)
+ if (wallet === undefined || account === undefined) {
+ return { ok: false, reason: 'wallet_error', detail: 'Reconnect your wallet and try again.' }
+ }
+ try {
+ return { ok: true, signed: await sign(wallet, account, payload) }
+ } catch (e) {
+ const detail = walletErrorMessage(e)
+ return isUserRejection(detail)
+ ? { ok: false, reason: 'rejected' }
+ : { ok: false, reason: 'wallet_error', detail }
+ }
+ },
+ disconnect: async () => {
+ const current = installed().find((w) => w.name === store.get().walletName)
+ forget()
+ const feature = (current?.features as Partial | undefined)?.[
+ StandardDisconnect
+ ]
+ await feature?.disconnect().catch(() => undefined)
+ },
+ }
+}
diff --git a/apps/dapp/lib/wallet/store-connector.ts b/apps/dapp/lib/wallet/store-connector.ts
new file mode 100644
index 00000000..d0e74ae3
--- /dev/null
+++ b/apps/dapp/lib/wallet/store-connector.ts
@@ -0,0 +1,12 @@
+import type { SignedOutcome, SigningPayload } from '../chains/types'
+import type { WalletConnector } from './connector'
+import type { WalletStore } from './wallet-store'
+
+/** A connector whose connection lives in a `WalletStore`. */
+export interface StoreConnector extends WalletConnector {
+ store: WalletStore
+ /** Reconnects the remembered wallet without prompting. Safe to call repeatedly. */
+ restore: () => Promise
+ disconnect: () => Promise
+ signPrepared: (payload: SigningPayload) => Promise
+}
diff --git a/apps/dapp/lib/wallet/wallet-store.ts b/apps/dapp/lib/wallet/wallet-store.ts
new file mode 100644
index 00000000..410da094
--- /dev/null
+++ b/apps/dapp/lib/wallet/wallet-store.ts
@@ -0,0 +1,74 @@
+/** The connected wallet of one chain, for the chains whose libraries keep no React state. */
+export interface WalletStoreState {
+ address: string | undefined
+ walletName: string | undefined
+ walletIcon: string | undefined
+ /** A remembered wallet is being reconnected, so "not connected" is not yet an answer. */
+ isRestoring: boolean
+}
+
+export interface WalletStore {
+ get: () => WalletStoreState
+ getServer: () => WalletStoreState
+ set: (next: Partial) => void
+ subscribe: (listener: () => void) => () => void
+ /** The wallet to reconnect after a reload, if any. */
+ remembered: () => string | undefined
+ remember: (walletId: string | undefined) => void
+}
+
+const SERVER_STATE: WalletStoreState = {
+ address: undefined,
+ walletName: undefined,
+ walletIcon: undefined,
+ isRestoring: true,
+}
+
+/**
+ * A store outside React, so every component reading a chain's wallet shares one
+ * connection and it survives moving between screens.
+ */
+export function createWalletStore(
+ key: string,
+ storage: () => Pick | undefined = browserStorage
+): WalletStore {
+ function remembered(): string | undefined {
+ try {
+ return storage()?.getItem(key) ?? undefined
+ } catch {
+ // Private-mode browsers throw here. The wallet still works for this tab.
+ return undefined
+ }
+ }
+
+ const listeners = new Set<() => void>()
+ let state: WalletStoreState = { ...SERVER_STATE, isRestoring: remembered() !== undefined }
+
+ return {
+ get: () => state,
+ getServer: () => SERVER_STATE,
+ set: (next) => {
+ state = { ...state, ...next }
+ for (const notify of listeners) notify()
+ },
+ subscribe: (listener) => {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ },
+ remembered,
+ remember: (walletId) => {
+ try {
+ if (walletId === undefined) storage()?.removeItem(key)
+ else storage()?.setItem(key, walletId)
+ } catch {
+ /* see remembered() */
+ }
+ },
+ }
+}
+
+function browserStorage(): Storage | undefined {
+ return typeof window === 'undefined' ? undefined : window.localStorage
+}
diff --git a/apps/dapp/middleware.ts b/apps/dapp/middleware.ts
index 670e8b68..c53429fd 100644
--- a/apps/dapp/middleware.ts
+++ b/apps/dapp/middleware.ts
@@ -5,7 +5,6 @@ import {
NETWORK_HEADER,
defaultNetwork,
enabledNetworks,
- homeSegment,
resolveRequestedNetwork,
} from '@intent/config'
@@ -13,11 +12,14 @@ import { gateEnabled } from './lib/server/access-gate'
import { constantTimeEqual } from './lib/server/constant-time-equal'
import {
bareRedirect,
+ homeFor,
+ rememberedChain,
isGateFree,
legacyStellarRedirect,
segmentNetwork,
} from './lib/server/network-route'
import { SESSION_COOKIE } from './lib/server/session-constants'
+import { CHAIN_COOKIE } from './lib/wallet/chain-cookie'
/**
* Blocks the dApp behind the access gate.
@@ -97,8 +99,15 @@ export async function middleware(request: NextRequest): Promise {
const moved = legacyStellarRedirect(pathname, search, multi, fallback)
if (moved !== undefined) return NextResponse.redirect(new URL(moved, request.url))
- // The bare root and the old unprefixed screens open on the home chain.
- const home = bareRedirect(pathname, search, homeSegment(enabled))
+ // The bare root is the connect page, unless a chain is remembered. The old
+ // unprefixed screens open on the chain last connected to, or the home chain.
+ const cookie = request.cookies.get(CHAIN_COOKIE)?.value
+ const home = bareRedirect(
+ pathname,
+ search,
+ homeFor(cookie, enabled),
+ rememberedChain(cookie, enabled)
+ )
if (home !== undefined) return NextResponse.redirect(new URL(home, request.url))
}
diff --git a/apps/dapp/next.config.js b/apps/dapp/next.config.js
index a6a1b9f2..b20929de 100644
--- a/apps/dapp/next.config.js
+++ b/apps/dapp/next.config.js
@@ -21,6 +21,9 @@ const config = {
// this, and the matching audit ignore in the root package.json, once the
// app is on a patched line.
images: { unoptimized: true },
+ // Dev only. The defaults (60s, 5 entries) count every polled API route as an
+ // entry, so pages kept being dropped and recompiled, about 20s per click.
+ onDemandEntries: { maxInactiveAge: 60 * 60 * 1000, pagesBufferLength: 200 },
}
/**
diff --git a/apps/dapp/package.json b/apps/dapp/package.json
index 7a4fec14..96dde7d2 100644
--- a/apps/dapp/package.json
+++ b/apps/dapp/package.json
@@ -16,21 +16,29 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^1.3.0",
+ "@cetusprotocol/aggregator-sdk": "^1.7.5",
"@creit.tech/stellar-wallets-kit": "^2.6.0",
"@dicebear/core": "9.2.2",
"@dicebear/pixel-art": "9.2.2",
"@hookform/resolvers": "^3.9.0",
+ "@hot-labs/near-connect": "^0.12.0",
"@intent/config": "workspace:*",
"@intent/sdk": "workspace:*",
"@intent/types": "workspace:*",
"@intent/ui": "workspace:*",
+ "@mysten/sui": "2.31.2",
+ "@naviprotocol/lending": "^2.1.0",
"@noble/hashes": "^1.8.0",
"@rainbow-me/rainbowkit": "^2.1.3",
"@sentry/nextjs": "^11.0.0",
+ "@solana/kit": "^8.6.0",
"@stellar/freighter-api": "^6.0.1",
"@stellar/stellar-sdk": "^17.0.1",
"@tanstack/react-query": "^5.51.1",
"@tanstack/react-query-devtools": "^5.51.1",
+ "@wallet-standard/app": "^1.1.1",
+ "@wallet-standard/base": "^1.1.1",
+ "@wallet-standard/features": "^1.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.18.2",
diff --git a/apps/dapp/playwright.config.ts b/apps/dapp/playwright.config.ts
index 71be5c05..4425f48c 100644
--- a/apps/dapp/playwright.config.ts
+++ b/apps/dapp/playwright.config.ts
@@ -47,6 +47,23 @@ export default defineConfig({
// A retried run records the attempt, so a flake that passes on retry still
// leaves evidence of the first failure.
trace: 'on-first-retry',
+ // Past the wallet gate: these specs check what pages render without a wallet.
+ // `connect-gate.spec.ts` clears this to test the gate itself.
+ storageState: {
+ cookies: [
+ {
+ name: 'e2e-no-wallet-gate',
+ value: '1',
+ domain: 'localhost',
+ path: '/',
+ expires: -1,
+ httpOnly: false,
+ secure: false,
+ sameSite: 'Lax',
+ },
+ ],
+ origins: [],
+ },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
@@ -57,6 +74,7 @@ export default defineConfig({
timeout: 10 * 60 * 1000,
env: {
AGENT_BRAIN: 'mock',
+ E2E_WALLET_GATE_BYPASS: '1',
SKIP_LIVE: '1',
NEXT_TELEMETRY_DISABLED: '1',
// Blank rather than unset: Next only fills a variable from `.env.local`
diff --git a/apps/dapp/providers/chain-provider.tsx b/apps/dapp/providers/chain-provider.tsx
index 9cbca91d..26d8a0b2 100644
--- a/apps/dapp/providers/chain-provider.tsx
+++ b/apps/dapp/providers/chain-provider.tsx
@@ -12,13 +12,12 @@ import { createContext, useContext, useMemo } from 'react'
import { installNetworkFetch } from '../lib/api/network-fetch'
import type { ChainAdapter } from '../lib/chain-adapter'
-import { arcAdapter } from '../lib/adapters/arc-adapter'
-import { stellarAdapterFor } from '../lib/adapters/stellar-adapter'
+import { adapterFor } from '../lib/adapters'
interface ChainContextValue {
- /** The chain family: `arc` or `stellar`. Never carries the network. */
+ /** The chain, such as `near` or `stellar`. Never carries the network. */
slug: ChainSlug
- /** The Stellar network of this page. Absent on Arc. */
+ /** The Stellar network of this page. Absent on every other chain. */
network: StellarNetworkName | undefined
/** The path segment this page is under, to build links that stay on it. */
segment: string
@@ -51,8 +50,7 @@ export function ChainProvider({
}
const value = useMemo(() => {
- const adapter =
- route.slug === 'arc' ? arcAdapter : stellarAdapterFor(network as StellarNetworkName)
+ const adapter = adapterFor(route.slug, network)
return { slug: route.slug, network, segment, descriptor: adapter.descriptor, adapter }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segment])
diff --git a/apps/dapp/providers/root.tsx b/apps/dapp/providers/root.tsx
index b4b151ce..2c27f01b 100644
--- a/apps/dapp/providers/root.tsx
+++ b/apps/dapp/providers/root.tsx
@@ -1,14 +1,17 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { RainbowKitProvider, lightTheme } from '@rainbow-me/rainbowkit'
import { WagmiProvider } from 'wagmi'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'
+import { quietExtensionErrors } from '../lib/extension-console'
import { wagmiConfig } from '../lib/wagmi.config'
-import '@rainbow-me/rainbowkit/styles.css'
+// At module load, so it wraps the dev overlay's console hook, which is already in place.
+if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
+ quietExtensionErrors(console)
+}
export function RootProviders({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -23,23 +26,14 @@ export function RootProviders({ children }: { children: React.ReactNode }) {
return (
-
-
- {children}
-
-
+ {children}
+
)
diff --git a/apps/dapp/public/images/venues/1click-earn.webp b/apps/dapp/public/images/venues/1click-earn.webp
new file mode 100644
index 0000000000000000000000000000000000000000..bfb03c75d11fe921eb319e403dd916113930f69f
GIT binary patch
literal 1234
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
E0N_2+G5`Po
literal 0
HcmV?d00001
diff --git a/apps/dapp/public/images/venues/bucket.webp b/apps/dapp/public/images/venues/bucket.webp
new file mode 100644
index 0000000000000000000000000000000000000000..d7f44dfdf0df11cd5cd909909b6a9fe224393a95
GIT binary patch
literal 866
zcmV-o1D*U*Nk&Fm0{{S5MM6+kP&gn?0{{T<6abw8Du4ih06sk)j6@%0122`)`1$$vM`H~uGhBIvYTc)_M0{Db{>+jqtf_uqA1(!PEFh5h^B
zFZmDj-}e28e`bA#zEH|NOl_ww%rZFHAeVF%B{g?>+ySco+y*3~>;*f98J(L@GGRP(?B|XKOQagFG-(
z|BQTWUyzqlSa;%|v?l}i{25l=Se_UOkSoSn9qi+2=gb*4@6VwZbKh5zmc!KQpSE)ba!w>%e7R;vk%V`Y@7*<|de-UbRmw;`2q0+;1
zxT!)pu}+|v0B7}9{-E**dy+tXGlMkk?<9k^p<~o#D>}-K|HAvQcvCm&HH`LCs()7)
zel>K1z4>Rmwc&-e1kq%zaM0!G<)
s!p)Zo79f6w(rC10)ZS{qLQLjA?WD000000CK;p(EtDd
literal 0
HcmV?d00001
diff --git a/apps/dapp/public/images/venues/busha.webp b/apps/dapp/public/images/venues/busha.webp
new file mode 100644
index 0000000000000000000000000000000000000000..bb6f3541174d60d4875ab978b2f5e0b041bfdbc0
GIT binary patch
literal 1264
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
literal 0
HcmV?d00001
diff --git a/apps/dapp/public/images/venues/deepbook.webp b/apps/dapp/public/images/venues/deepbook.webp
new file mode 100644
index 0000000000000000000000000000000000000000..a2c3b7e2060f5727aadcc32170320c56c801d87d
GIT binary patch
literal 984
zcmV;}11J1aNk&G{0{{S5MM6+kP&gpO0{{SU7XY0BDu4ih06sk)ibSFziG@}k5CP&?
z!M-^{I1rV;WGmXQ2VPs#!#`lbV#
z*Un*$4GuM^wz+da$4YJ{0FF>Ps)H(|e3}3L$R4zRhnUmWuCM?7BIe4VzSGxaP@*Lu
zjS8Sgl1EQy%rLSWs!p{>#to5qn#2vS1YmY4%A@F(n7(q65~7hCY$gi7PO;oq$HY1%
z2(XLDsZ8o6lYRVtZ$jVz{`TGN)20neKm02NKlqb7-|-=T;zIxWF!STH2iOB;{~Td!
zyhXM&*ZeD}N9coWq^_|;M|U5^P$I>_fr(vrk<)42C2(9g*+2BKl}RqIZH|T1|w&7eQ
z?*{=dR-bJ+EAiu*_7m_zGJHbLJv5qUv4dh`W1g<4p~sV&SJjW+d}|-zv@u2dE));76|8GWzipY`!Hx#z~T>i6rA@~lo)Pyf%tS`hp@0%HGdSic2J
zVvdiitU>{VbT=;f(#_qbiWi>mP66t}fmhW`A3$ZBhygU|<=qifsL%6rTN|K&vh?aJ
zHJ~MxeJikiFiFZ{c8b2#eD=*NJi?p&I&egXm)MjB>L{l2pHX4AXnK5um;Wb!_&=#d
zFd^kQPHc=7*h?*!v>(lVQ*j+l3=9;6WC#QOC|Kg>9epoOyx#L9eU!;c(330PeKJ?k
z@+0v*v%d9B(XU2c16#wVkNbjUYn=Tz+7|A|fM|aez1zkEOX0nuGqg&)y=7zfWpAH?QsnOQ@t!Y@?|U3z^uyl=fQ2WmDB)My&oBOK?QwaAze7L$4;+y%
zE6ZC)ip~YKN6x>fVJd;Po>Zw^MG|Z;Vw9w352$w}LXYzVE6yndaMK>kZ55f+&z%D0
zJ7LUR^Hn|8^V*bePxVTHn!$-eRRr`wYPfIa2NF!+6}wccCf=a`EA~cu9bf*>kEZ`?
zP-Yl)_q5u(Zzn8{&o%V+c=^13Ink>J?5s?o#10s^h6`BqT0+*`{jZR%5ImKh=~1Z`
GfB*oTRPHDM
literal 0
HcmV?d00001
diff --git a/apps/dapp/public/images/venues/ember.webp b/apps/dapp/public/images/venues/ember.webp
new file mode 100644
index 0000000000000000000000000000000000000000..5094450e5c62784c4f49a1df35622f10d404ec9d
GIT binary patch
literal 2674
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
literal 0
HcmV?d00001
diff --git a/apps/dapp/public/images/venues/haedal.webp b/apps/dapp/public/images/venues/haedal.webp
new file mode 100644
index 0000000000000000000000000000000000000000..7b93eb01b6cf2194cf08184f8efd718839d88765
GIT binary patch
literal 1594
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