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' ? ( - - ) : ( - {chain} - ) - )} + {venue.chains.slice(0, 3).map((chain) => { + const badge = chainMarkFor(chain) + return badge.kind === 'mark' ? ( + + ) : ( + {chain} + ) + })}
) : 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) => ( + + ))} +
+ ) +} + +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 ( + + + ) + } + if (wallets.isError) { + return ( + + Could not load {name} wallets. Check your connection and 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 ? ( + + ) : ( + + + {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. +

+ +
+ )} +
+
+
+ +
+ +

+ 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 ( + + ) +} 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 ( +
+
+

+ {TYPED} +

+ +
+
    + {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 ( +
    +
    +

    You send

    + +
    +

    + 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. +

    + )}
    +
    + + ) : null} + + {phase === 'pending' ? ( + <> +

    + {error} +

    +
    + +
    + + ) : 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 ? ( + + ) : 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 ( ) @@ -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 ? ( + + ) : ( +