diff --git a/apps/dapp/.env.example b/apps/dapp/.env.example index 670e5add..92a45075 100644 --- a/apps/dapp/.env.example +++ b/apps/dapp/.env.example @@ -46,7 +46,13 @@ NEXT_PUBLIC_STELLAR_NETWORKS= # Where the other Stellar network is deployed, for a deployment that serves only # one (a mainnet deployment points at the testnet one, and the other way round). # Adds it to the chain menu as a link. Ignored when NEXT_PUBLIC_STELLAR_NETWORKS -# lists both networks. Leave empty and the menu shows it as not set up. +# lists both networks, and on mainnet, which never links to testnet. Leave empty +# and the menu does not mention the other network. +# +# Production serves mainnet only, so testnet is not reachable there: +# NEXT_PUBLIC_STELLAR_NETWORK=mainnet +# NEXT_PUBLIC_STELLAR_NETWORKS=mainnet +# Locally, list both to switch between them: testnet,mainnet NEXT_PUBLIC_STELLAR_OTHER_NETWORK_URL= # The most one trade may be worth on mainnet, in dollars, checked against the # same prices the agents reason from. Ignored on testnet. Default 500. @@ -195,3 +201,13 @@ NEXT_PUBLIC_SENTRY_DSN= # a day while it stays there. ALERT_EMAIL= SPONSOR_ALERT_XLM=20 + +# --- Cash-outs through Weave --- +# Server-only key for api.paywithweave.com. It can create real orders; never prefix it NEXT_PUBLIC. Unset, cash-outs are off. +WEAVE_SECRET_KEY= +# Signs Weave's webhooks to /api/cashout/webhook. Unset, every webhook is refused with a 401. +WEAVE_WEBHOOK_SECRET= +# Comma list from NGN,KES,UGX,TZS. Unset means NGN only, because no real payout has been tested for the others. +WEAVE_CASHOUT_CURRENCIES= +# Avalanche C-Chain RPC used to build and confirm USDC deposits. +AVALANCHE_RPC_URL= diff --git a/apps/dapp/app/[chain]/apps/page.tsx b/apps/dapp/app/[chain]/apps/page.tsx index 512904ec..733c3adc 100644 --- a/apps/dapp/app/[chain]/apps/page.tsx +++ b/apps/dapp/app/[chain]/apps/page.tsx @@ -1,9 +1,17 @@ import { PageHeader } from '@intent/ui' import { VenueGrid } from '../../../components/apps/venue-grid' +import { parseChainSegment } from '@intent/config' + import { availableVenueIds } from '../../../lib/venues' -export default function AppsPage(): JSX.Element { +export default async function AppsPage({ + params, +}: { + params: Promise<{ chain: string }> +}): Promise { + const route = parseChainSegment((await params).chain) + const racing = route?.slug === 'stellar' || route?.slug === 'arc' // Decided here, on the server: which venues exist on this network depends // on configuration the browser cannot see. const availableIds = [...availableVenueIds()] @@ -11,7 +19,11 @@ export default function AppsPage(): JSX.Element {
diff --git a/apps/dapp/app/[chain]/intents/[id]/page.tsx b/apps/dapp/app/[chain]/intents/[id]/page.tsx index 0d39f003..55ef990b 100644 --- a/apps/dapp/app/[chain]/intents/[id]/page.tsx +++ b/apps/dapp/app/[chain]/intents/[id]/page.tsx @@ -1,11 +1,12 @@ 'use client' import type { IntentStatus } from '@intent/types' -import { Badge, Button, Card, Separator, cn } from '@intent/ui' +import { Button, Card, Separator, cn } from '@intent/ui' import { ArrowRight, Check, ExternalLink, Loader2 } from 'lucide-react' import Link from 'next/link' import { useParams } from 'next/navigation' +import { chainTitle } from '../../../../lib/chain-title' import { IntentStatusBadge } from '../../../../components/intents/intent-status-badge' import { useCancelIntent, useIntent } from '../../../../hooks/use-intent' import { intentTypeLabel } from '../../../../lib/intent-format' @@ -193,9 +194,8 @@ export default function IntentDetailPage(): JSX.Element { {/* Read from the active chain rather than hardcoded: this said "USDC on Arc" on every intent, including Stellar ones signed with a Stellar wallet. */} -

- {intent.tokenIn} on {descriptor.name}{' '} - {descriptor.networkLabel} +

+ {intent.tokenIn} on {chainTitle(descriptor.name, descriptor.networkLabel)}

diff --git a/apps/dapp/app/[chain]/layout.tsx b/apps/dapp/app/[chain]/layout.tsx index 6f945282..3dd77a70 100644 --- a/apps/dapp/app/[chain]/layout.tsx +++ b/apps/dapp/app/[chain]/layout.tsx @@ -1,6 +1,8 @@ import { chainSegments, parseChainSegment } from '@intent/config' +import { cookies } from 'next/headers' import { notFound } from 'next/navigation' +import { WalletGate } from '../../components/connect/wallet-gate' import { AppShell } from '../../components/layout/app-shell' import { tradeCapUsd } from '../../lib/server/trade-cap' import { ChainProvider } from '../../providers/chain-provider' @@ -35,9 +37,17 @@ export default async function ChainLayout({ cap = undefined } + // For the browser tests, which have no wallet. Needs the server's own setting + // as well as the cookie, so a visitor cannot switch the gate off in production. + const bypass = + process.env['E2E_WALLET_GATE_BYPASS'] === '1' && + (await cookies()).get('e2e-no-wallet-gate') !== undefined + return ( - {children} + + {children} + ) } diff --git a/apps/dapp/app/[chain]/loading.tsx b/apps/dapp/app/[chain]/loading.tsx new file mode 100644 index 00000000..29ef2933 --- /dev/null +++ b/apps/dapp/app/[chain]/loading.tsx @@ -0,0 +1,10 @@ +import { Loader2 } from 'lucide-react' + +/** Shown inside the frame the moment a link is followed, while the next screen renders. */ +export default function ChainLoading(): JSX.Element { + return ( +
+ +
+ ) +} diff --git a/apps/dapp/app/api/cashout/balance/route.ts b/apps/dapp/app/api/cashout/balance/route.ts new file mode 100644 index 00000000..315fe6db --- /dev/null +++ b/apps/dapp/app/api/cashout/balance/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { reportError } from '../../../../lib/server/report' +import { depositChain } from '../../../../lib/weave/deposit' +import { guardCashout } from '../../../../lib/weave/guard' +import { isCashoutChain } from '../../../../lib/weave/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function displayUnits(units: bigint, decimals: number): string { + const text = units.toString().padStart(decimals + 1, '0') + const whole = text.slice(0, -decimals) + const fraction = text.slice(-decimals).replace(/0+$/, '') + return fraction === '' ? whole : `${whole}.${fraction}` +} + +/** How much USDC a wallet holds on a chain, for the form's "Available". */ +export async function GET(request: Request): Promise { + const query = new URL(request.url).searchParams + const chain = query.get('chain') ?? '' + if (!isCashoutChain(chain)) { + return NextResponse.json({ error: 'unknown_chain' }, { status: 400 }) + } + const guard = guardCashout(request, chain) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const deposit = depositChain(chain) + const account = query.get('account') ?? '' + if (!deposit.isAccount(account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + try { + const units = await deposit.usdcBalance(account) + return NextResponse.json({ balance: displayUnits(units, deposit.usdcDecimals) }) + } catch (e) { + // Not answered as zero: the form would then say the wallet can't afford this. + reportError('cashout/balance', e, { chain }) + return NextResponse.json({ error: 'unavailable' }, { status: 503 }) + } +} diff --git a/apps/dapp/app/api/cashout/institutions/route.ts b/apps/dapp/app/api/cashout/institutions/route.ts new file mode 100644 index 00000000..32a3402e --- /dev/null +++ b/apps/dapp/app/api/cashout/institutions/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { createWeaveClient } from '../../../../lib/weave/client' +import { isCashoutCurrency, liveCurrencies } from '../../../../lib/weave/corridors' +import { guardCashout } from '../../../../lib/weave/guard' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** The banks and mobile money services a currency can be paid out to. */ +export async function GET(request: Request): Promise { + const guard = guardCashout(request) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const currency = new URL(request.url).searchParams.get('currency') ?? '' + if (!isCashoutCurrency(currency) || !liveCurrencies().includes(currency)) { + return NextResponse.json( + { error: 'unknown_currency', message: 'Cash-outs do not pay out in that currency.' }, + { status: 400 } + ) + } + + const result = await createWeaveClient().institutions(currency) + if (!result.ok) { + const status = result.code === 'rejected' ? 422 : 503 + return NextResponse.json({ error: result.code, message: result.message }, { status }) + } + return NextResponse.json({ institutions: result.value }) +} diff --git a/apps/dapp/app/api/cashout/options/route.ts b/apps/dapp/app/api/cashout/options/route.ts new file mode 100644 index 00000000..bff4d138 --- /dev/null +++ b/apps/dapp/app/api/cashout/options/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' + +import { CORRIDORS, isCurrencyAllowed, liveCurrencies } from '../../../../lib/weave/corridors' +import { guardCashout } from '../../../../lib/weave/guard' +import { isCashoutChain } from '../../../../lib/weave/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** The currencies this chain can cash out to right now. */ +export async function GET(request: Request): Promise { + const chain = new URL(request.url).searchParams.get('chain') ?? '' + if (!isCashoutChain(chain)) { + return NextResponse.json( + { error: 'unknown_chain', message: 'Cash-outs are not available on this chain.' }, + { status: 400 } + ) + } + const guard = guardCashout(request, chain) + if (!guard.ok) return guard.response + + return NextResponse.json({ + currencies: liveCurrencies() + .filter((code) => isCurrencyAllowed(chain, code)) + .map((code) => CORRIDORS[code]), + }) +} diff --git a/apps/dapp/app/api/cashout/orders/[id]/build/route.ts b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts new file mode 100644 index 00000000..7f86c7de --- /dev/null +++ b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from 'next/server' + +import { ChainActionRefused } from '../../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../../lib/server/report' +import { createWeaveClient } from '../../../../../../lib/weave/client' +import { depositChain } from '../../../../../../lib/weave/deposit' +import { chainRefusal, guardCashout } from '../../../../../../lib/weave/guard' +import { + depositExpectation, + getWeaveOrderRepo, + payRefusal, + safeForReport, + SEND_STARTED, + syncOrder, +} from '../../../../../../lib/weave/orders' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * Builds the deposit for a stored order, ready for signature. + * + * The browser names an order; it does not name a destination or an amount. Both are + * read from the stored row, and what the chain's builder produced is read back against + * them before anyone is asked to sign it. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> } +): Promise { + const guard = guardCashout(request) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const repo = await getWeaveOrderRepo() + const row = await repo.getForEmail((await context.params).id, guard.email) + if (row === undefined) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + const onThisNetwork = chainRefusal(row.chain) + if (onThisNetwork !== undefined) return onThisNetwork + if (row.sendStarted) { + return NextResponse.json({ error: 'send_started', message: SEND_STARTED }, { status: 409 }) + } + + const refused = payRefusal(await syncOrder(repo, row, createWeaveClient())) + if (refused !== undefined) { + return NextResponse.json( + { error: refused.code, message: `${refused.reason} Nothing was signed or sent.` }, + { status: refused.status } + ) + } + + const deposit = depositChain(row.chain) + const expectation = depositExpectation(row) + try { + const signing = await deposit.build(expectation) + deposit.assertSafe(signing, expectation) + return NextResponse.json({ signing }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ error: 'refused', message: e.message }, { status: 422 }) + } + reportError('cashout/build', safeForReport(e, row), { chain: row.chain, orderId: row.id }) + return NextResponse.json( + { + error: 'build_failed', + message: + 'The deposit could not be prepared. Nothing was signed or sent. Try again shortly.', + }, + { status: 502 } + ) + } +} diff --git a/apps/dapp/app/api/cashout/orders/[id]/route.ts b/apps/dapp/app/api/cashout/orders/[id]/route.ts new file mode 100644 index 00000000..de62b9f0 --- /dev/null +++ b/apps/dapp/app/api/cashout/orders/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' + +import { createWeaveClient } from '../../../../../lib/weave/client' +import { guardCashout } from '../../../../../lib/weave/guard' +import { getWeaveOrderRepo, syncOrder, toPublic } from '../../../../../lib/weave/orders' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * One order's state. Reconciled with Weave on every read while it is still moving, + * because a webhook can be missed, and falls back to the stored row when Weave cannot + * be reached. + */ +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> } +): Promise { + const guard = guardCashout(request) + if (!guard.ok) return guard.response + + const repo = await getWeaveOrderRepo() + // A wrong id and somebody else's id are the same answer. + const row = await repo.getForEmail((await context.params).id, guard.email) + if (row === undefined) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + + const synced = await syncOrder(repo, row, createWeaveClient()) + return NextResponse.json({ order: toPublic(synced.row) }) +} diff --git a/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts b/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts new file mode 100644 index 00000000..a60c8396 --- /dev/null +++ b/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts @@ -0,0 +1,215 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { ChainActionRefused, type SignedPayload } from '../../../../../../lib/chains/types' +import { logExecution } from '../../../../../../lib/server/analytics' +import { enforceRateLimit } from '../../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../../lib/server/report' +import { createWeaveClient } from '../../../../../../lib/weave/client' +import { depositChain } from '../../../../../../lib/weave/deposit' +import { chainRefusal, guardCashout } from '../../../../../../lib/weave/guard' +import { + depositExpectation, + getWeaveOrderRepo, + payRefusal, + safeForReport, + SEND_STARTED, + syncOrder, + type WeaveOrderRepo, + type WeaveOrderRow, +} from '../../../../../../lib/weave/orders' +import type { CashoutChain } from '../../../../../../lib/weave/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +// A chain's submit can poll for its confirmation: Stellar and Avalanche for about 20 to 25 seconds, Solana for about 60. +export const maxDuration = 60 + +const Signed = z.discriminatedUnion('family', [ + z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }), + z.object({ + family: z.literal('sui'), + transaction: z.string().min(1).max(262144), + signature: z.string().min(1).max(1024), + }), + z.object({ + family: z.literal('near'), + hashes: z.array(z.string().min(1).max(128)).min(1).max(8), + }), + z.object({ family: z.literal('evm'), hash: z.string().min(1).max(128) }), + z.object({ family: z.literal('stellar'), signedXdr: z.string().min(1).max(262144) }), +]) + +const Body = z.object({ signed: Signed }) + +function familyOf(chain: CashoutChain): SignedPayload['family'] { + return chain === 'avalanche' ? 'evm' : chain +} + +/** These wallets send for themselves, so by the time this route hears of a transfer it has gone. */ +function walletSent(signed: SignedPayload): boolean { + return signed.family === 'near' || signed.family === 'evm' +} + +function refuse(code: string, error: string, status: number): Response { + return NextResponse.json({ ok: false, code, error }, { status }) +} + +/** + * A transfer happened whatever the record says, so a database that cannot be written + * to never changes the answer. A hash already counted does: that transfer is not a + * payment of this order, however well it matches. + */ +async function storeHash( + repo: WeaveOrderRepo, + row: WeaveOrderRow, + hash: string +): Promise<'stored' | 'reused'> { + try { + return await repo.setDepositHash(row.id, hash) + } catch (e) { + reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id }) + return 'stored' + } +} + +/** + * Sends the signed deposit for an order, or for wallets that send for themselves + * confirms what they sent. + * + * The order is read and asked about again: the signature took a while, and it may + * have ended or been paid since. What the deposit must be comes from the row, and the + * chain checks the bytes against it, because they passed through a browser and a + * wallet extension since they were built. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> } +): Promise { + const guard = guardCashout(request) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'submit') + if (limited !== undefined) return limited + + const parsed = Body.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return refuse( + 'invalid_body', + 'This page is out of date. Reload it and try again. Nothing was sent.', + 400 + ) + } + const signed = parsed.data.signed as SignedPayload + + const repo = await getWeaveOrderRepo() + const row = await repo.getForEmail((await context.params).id, guard.email) + if (row === undefined) + return NextResponse.json({ ok: false, code: 'not_found', error: 'not_found' }, { status: 404 }) + const onThisNetwork = chainRefusal(row.chain) + if (onThisNetwork !== undefined) return onThisNetwork + + if (signed.family !== familyOf(row.chain)) { + return refuse('wrong_chain', 'This was signed for a different chain. Nothing was sent.', 400) + } + + const sentAlready = walletSent(signed) + const synced = await syncOrder(repo, row, createWeaveClient()) + // A wallet that sends for itself has already moved the money, and Weave may have + // seen it by now, so this only confirms and records it whatever the order's status. + const refused = !sentAlready || row.depositHash !== undefined ? payRefusal(synced) : undefined + if (refused !== undefined) { + // Saying nothing was sent would be untrue here: this wallet has already sent it. + const tail = sentAlready + ? 'Your wallet has already sent this transfer, so do not send it again. Check your wallet if you are unsure.' + : 'Nothing was sent.' + return refuse(refused.code, `${refused.reason} ${tail}`, refused.status) + } + + // Taken before sending and kept unless the send is known to have failed, so a + // crash, a timeout or a reload cannot lead to a second deposit. + let claimed = false + try { + claimed = await repo.claimSend(row.id) + } catch (e) { + reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id }) + if (!sentAlready) { + return refuse( + 'unavailable', + 'This deposit could not be recorded, so it was not sent. Nothing was sent. Try again in a moment.', + 503 + ) + } + } + if (!claimed && !sentAlready) { + return NextResponse.json( + { ok: false, pending: true, code: 'send_started', error: SEND_STARTED }, + { status: 409 } + ) + } + + const release = async (): Promise => { + if (!claimed) return + await repo.releaseSend(row.id).catch((e: unknown) => { + reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id }) + }) + } + + try { + const result = await depositChain(row.chain).submit(signed, depositExpectation(row)) + const hash = (result as { hash?: unknown }).hash + const hashText = typeof hash === 'string' && hash !== '' ? hash : undefined + const pending = !result.ok && result.pending === true + + const recorded = + hashText !== undefined && (result.ok || pending) + ? await storeHash(repo, row, hashText) + : 'stored' + const reused = recorded === 'reused' + if (reused || (!result.ok && !pending)) await release() + + await logExecution({ + kind: 'offramp', + account: row.account, + feeSponsored: false, + network: 'mainnet', + // Sent with no outcome yet is its own thing in the numbers, not an unknown failure. + result: reused + ? { ok: false, reason: 'hash_reused' } + : pending + ? { ...result, reason: 'pending' } + : result, + ...(signed.family === 'stellar' && !reused ? { signedXdr: signed.signedXdr } : {}), + }) + + if (reused) { + reportError('cashout/submit', new Error('deposit_hash_reused'), { + chain: row.chain, + orderId: row.id, + }) + return refuse( + 'hash_reused', + "This transfer is already on record as a deposit, so it does not pay this cash-out. Do not send it again. Check your wallet's activity.", + 409 + ) + } + + // The status moves only on what Weave says, so even a good answer here changes + // nothing but the hash, and the chain's own wording reaches the person unchanged. + return NextResponse.json(result, { status: result.ok ? 200 : pending ? 202 : 422 }) + } catch (e) { + if (e instanceof ChainActionRefused) { + await release() + return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) + } + reportError('cashout/submit', safeForReport(e, row), { chain: row.chain, orderId: row.id }) + return NextResponse.json( + { + ok: false, + pending: true, + error: + "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", + }, + { status: 502 } + ) + } +} diff --git a/apps/dapp/app/api/cashout/orders/route.ts b/apps/dapp/app/api/cashout/orders/route.ts new file mode 100644 index 00000000..6692fe8d --- /dev/null +++ b/apps/dapp/app/api/cashout/orders/route.ts @@ -0,0 +1,312 @@ +import { NextResponse } from 'next/server' +import { randomUUID } from 'node:crypto' +import { z } from 'zod' + +import { databaseConfigured } from '../../../../lib/server/db' +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { reportError } from '../../../../lib/server/report' +import { requesterOf } from '../../../../lib/server/requester' +import { assertWithinCap, TradeCapExceeded, tradeCapUsd } from '../../../../lib/server/trade-cap' +import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/client' +import { isCurrencyAllowed } from '../../../../lib/weave/corridors' +import { depositChain } from '../../../../lib/weave/deposit' +import { chainRefusal, guardCashout } from '../../../../lib/weave/guard' +import { + cleanMemo, + getWeaveOrderRepo, + isPastExpiry, + MAX_COST_PERCENT, + orderMismatch, + readUsdcAmount, + safeForReport, + toPublic, +} from '../../../../lib/weave/orders' +import { nextStatus } from '../../../../lib/weave/status' +import { isCashoutChain } from '../../../../lib/weave/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const Body = z.object({ + chain: z.string().min(1).max(20), + account: z.string().min(1).max(128), + amount: z.string().min(1).max(40), + currency: z.string().min(1).max(8), + institution: z.string().min(1).max(64), + accountIdentifier: z.string().regex(/^\+?[0-9A-Za-z]{6,34}$/), + /** Minted once per attempt by the page and reused on a retry. */ + idempotencyKey: z.string().uuid(), +}) + +function refusal(error: string, message: string, status = 400): Response { + return NextResponse.json({ error, message }, { status }) +} + +/** What to tell the person about the first field that was refused. The value is never echoed. */ +function refusalFor(path: (string | number)[]): string { + switch (path[0]) { + case 'accountIdentifier': + return 'Check the account number and try again.' + case 'amount': + return 'Enter an amount of USDC greater than zero.' + case 'institution': + return 'Choose where the money should go.' + default: + return 'This page is out of date. Reload it and try again.' + } +} + +/** + * The cap always reads as mainnet, because a cash-out always is. Its own wording is + * for whoever runs the deployment, so a person gets this instead. + */ +function overCap(usd: number): Response | undefined { + try { + assertWithinCap(usd, process.env, 'mainnet') + return undefined + } catch (e) { + if (!(e instanceof TradeCapExceeded)) throw e + return refusal( + 'over_limit', + `This app caps each cash-out at $${tradeCapUsd(process.env, 'mainnet')}, and this one is about $${usd.toFixed(2)}. Try a smaller amount.` + ) + } +} + +/** This requester's cash-outs, newest first. Empty rather than an error when there is no record to read. */ +export async function GET(request: Request): Promise { + const who = requesterOf(request) + if (who === undefined) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + if (!databaseConfigured()) return NextResponse.json({ orders: [] }) + try { + const repo = await getWeaveOrderRepo() + const rows = await repo.listForEmail(who.email, 50) + return NextResponse.json({ orders: rows.map(toPublic) }) + } catch { + return NextResponse.json({ orders: [] }) + } +} + +/** + * Creates the cash-out order and returns what the review card shows. + * + * The holder's name is found here and the order is made with that name, never with + * one the browser sent: it is what the bank checks the payout against. Weave's answer + * is checked against what was asked before it is stored or shown, and the row is + * stored before the response, so a deposit address is never in a person's hands + * without a record of it. + */ +export async function POST(request: Request): Promise { + const guard = guardCashout(request) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const parsed = Body.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return refusal('invalid_body', refusalFor(parsed.error.issues[0]?.path ?? [])) + } + const input = parsed.data + + const { chain } = input + if (!isCashoutChain(chain)) { + return refusal('unknown_chain', 'Cash-outs are not available on this chain.') + } + const onThisNetwork = chainRefusal(chain) + if (onThisNetwork !== undefined) return onThisNetwork + + const deposit = depositChain(chain) + const amount = readUsdcAmount(input.amount, deposit.usdcDecimals) + if (amount === undefined) { + return refusal( + 'invalid_amount', + `Enter an amount of USDC greater than zero, with at most ${deposit.usdcDecimals} decimal places.` + ) + } + if (!deposit.isAccount(input.account)) { + return refusal('invalid_account', 'This wallet cannot make a deposit on this chain.') + } + const { currency } = input + if (!isCurrencyAllowed(chain, currency)) { + return refusal('unknown_currency', 'This chain does not cash out to that currency.') + } + + const repo = await getWeaveOrderRepo().catch(() => undefined) + const existing = await repo + ?.getByIdempotencyKey(input.idempotencyKey, guard.email) + .then((row) => ({ found: row })) + .catch(() => undefined) + // With no record to write to, no order may be made: the address could not be kept. + if (repo === undefined || existing === undefined) { + return refusal( + 'unavailable', + 'Cash-outs are not available right now. Nothing was signed or sent.', + 503 + ) + } + + // A retry of the same attempt returns the order already made, not a second. + if (existing.found !== undefined) { + const row = existing.found + // An order that ended unpaid cannot be paid. The page starts a new attempt on this code. + if (row.depositHash === undefined && (row.status !== 'waiting' || isPastExpiry(row))) { + return refusal( + 'order_closed', + 'That cash-out ended before it was paid. Review it again to start a new one.', + 409 + ) + } + return NextResponse.json({ order: toPublic(row) }) + } + + // A reload forgets what the page knew, so this is asked of the record: a person who + // can't see their earlier deposit must not be led to pay for the same thing again. + const unconfirmed = await repo.hasUnconfirmedSend(input.account).catch(() => undefined) + if (unconfirmed !== false) { + return refusal( + unconfirmed === undefined ? 'unavailable' : 'send_unconfirmed', + unconfirmed === undefined + ? 'Cash-outs are not available right now. Nothing was sent.' + : 'An earlier deposit from this wallet is not confirmed yet. Wait for it to settle before starting another. Nothing new was sent.', + unconfirmed === undefined ? 503 : 409 + ) + } + + const capped = overCap(Number(amount)) + if (capped !== undefined) return capped + + const weave = createWeaveClient() + const fail = (code: string, message: string): Response => + refusal(code, message, code === 'rejected' ? 422 : 503) + + const listed = await weave.institutions(currency) + if (!listed.ok) return fail(listed.code, listed.message) + const institution = listed.value.find((i) => i.code === input.institution) + if (institution === undefined) { + return refusal( + 'unknown_institution', + 'That bank or service is not available for this currency.' + ) + } + + // The holder is taken from this requester's own earlier order to the same account + // when there is one, and from Weave's lookup otherwise. Either way it is a name + // Weave returned, never one the browser sent. + let accountName = await repo + .findHolder(guard.email, currency, input.institution, input.accountIdentifier) + .catch(() => undefined) + if (accountName === undefined) { + const verified = await weave.verifyAccount({ + institution: input.institution, + accountIdentifier: input.accountIdentifier, + currency, + }) + if (!verified.ok) return fail(verified.code, verified.message) + accountName = verified.value.accountName + } + + const quote = await weave.quote({ chain, currency, amount }) + if (!quote.ok) return fail(quote.code, quote.message) + if (quoteCostPercent(amount, quote.value.netUsd) > MAX_COST_PERCENT) { + return refusal( + 'too_costly', + `This cash-out would cost more than ${MAX_COST_PERCENT}% of the amount right now, so it was not started. Nothing was signed or sent.`, + 422 + ) + } + + const made = await weave.createOrder({ + chain, + currency, + amount, + account: input.account, + institutionCode: input.institution, + accountIdentifier: input.accountIdentifier, + accountName, + reference: input.idempotencyKey, + customer: guard.email.startsWith('wallet:') + ? { externalId: guard.email } + : { email: guard.email }, + }) + if (!made.ok) { + if (made.code === 'duplicate') { + // Weave has an order this app has no record of, and its deposit address was + // never shown to anyone. The page starts a new attempt on this code. + return refusal('order_closed', made.message, 409) + } + if (made.code === 'unknown' || made.code === 'unexpected') { + reportError('cashout/orders', new Error(`order_${made.code}`), { chain, currency }) + } + const status = + made.code === 'rejected' + ? 422 + : made.code === 'unknown' + ? 504 + : made.code === 'unexpected' + ? 502 + : 503 + return refusal(made.code, made.message, status) + } + + const order = made.value + const mismatch = orderMismatch(order, { chain, currency, amount }, deposit) + if (mismatch !== undefined) { + reportError('cashout/orders', new Error('order_mismatch'), { + chain, + currency, + field: mismatch, + providerOrder: order.id, + }) + return refusal( + 'unexpected_order', + 'The cash-out order did not match the request, so nothing was signed or sent.', + 502 + ) + } + + const row = { + id: randomUUID(), + chain, + email: guard.email, + account: input.account, + providerOrder: order.id, + idempotencyKey: input.idempotencyKey, + currency, + institutionCode: input.institution, + institutionName: institution.name, + accountIdentifier: input.accountIdentifier, + accountName, + depositAddress: order.deposit.address, + depositMemo: cleanMemo(order.deposit.memo), + // What Weave needs to receive, which can be a little over what was asked. This + // is what the person signs, and orderMismatch has already bounded it. + amount: readUsdcAmount(order.deposit.amount.trim(), deposit.usdcDecimals) as string, + estimatedDest: String(quote.value.destAmount), + expiresAt: order.deposit.expiresAt, + status: nextStatus('waiting', order.status), + providerStatus: order.status, + } + try { + await repo.insert(row) + } catch (e) { + reportError('cashout/orders', safeForReport(e, row), { + chain, + currency, + providerOrder: order.id, + }) + return refusal( + 'not_recorded', + 'This cash-out could not be recorded, so nothing was signed or sent. Review it again to start a new one.', + 503 + ) + } + + return NextResponse.json({ + order: toPublic({ + ...row, + depositHash: undefined, + sendStarted: false, + createdAt: new Date().toISOString(), + }), + }) +} diff --git a/apps/dapp/app/api/cashout/quote/route.ts b/apps/dapp/app/api/cashout/quote/route.ts new file mode 100644 index 00000000..73cb5567 --- /dev/null +++ b/apps/dapp/app/api/cashout/quote/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/client' +import { isCurrencyAllowed } from '../../../../lib/weave/corridors' +import { depositChain } from '../../../../lib/weave/deposit' +import { guardCashout } from '../../../../lib/weave/guard' +import { readUsdcAmount } from '../../../../lib/weave/orders' +import { isCashoutChain } from '../../../../lib/weave/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function refuse(error: string, message: string): Response { + return NextResponse.json({ error, message }, { status: 400 }) +} + +/** What a cash-out would pay, what it would cost, and how long it should take. */ +export async function GET(request: Request): Promise { + const query = new URL(request.url).searchParams + const chain = query.get('chain') ?? '' + if (!isCashoutChain(chain)) { + return refuse('unknown_chain', 'Cash-outs are not available on this chain.') + } + const guard = guardCashout(request, chain) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const currency = query.get('currency') ?? '' + if (!isCurrencyAllowed(chain, currency)) { + return refuse('unknown_currency', 'This chain does not cash out to that currency.') + } + const amount = readUsdcAmount(query.get('amount') ?? '', depositChain(chain).usdcDecimals) + if (amount === undefined) { + return refuse('invalid_amount', 'Enter an amount of USDC greater than zero.') + } + + const result = await createWeaveClient().quote({ chain, currency, amount }) + if (!result.ok) { + const status = result.code === 'rejected' ? 422 : 503 + return NextResponse.json({ error: result.code, message: result.message }, { status }) + } + const cost = quoteCostPercent(amount, result.value.netUsd) + return NextResponse.json({ + destAmount: result.value.destAmount, + costPercent: Math.max(0, Math.round(cost * 100) / 100), + seconds: result.value.seconds, + }) +} diff --git a/apps/dapp/app/api/cashout/verify/route.ts b/apps/dapp/app/api/cashout/verify/route.ts new file mode 100644 index 00000000..65024703 --- /dev/null +++ b/apps/dapp/app/api/cashout/verify/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { enforceRateLimit } from '../../../../lib/server/rate-limit' +import { createWeaveClient } from '../../../../lib/weave/client' +import { isCashoutCurrency, liveCurrencies } from '../../../../lib/weave/corridors' +import { guardCashout } from '../../../../lib/weave/guard' +import { getWeaveOrderRepo } from '../../../../lib/weave/orders' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const Body = z.object({ + currency: z.string().min(1).max(8), + institution: z.string().min(1).max(64), + accountIdentifier: z.string().regex(/^\+?[0-9A-Za-z]{6,34}$/), +}) + +/** + * Looks up who an account belongs to, so the person can see it is the right one + * before any money moves. The identifier is passed through and never stored or + * logged here. + */ +export async function POST(request: Request): Promise { + const guard = guardCashout(request) + if (!guard.ok) return guard.response + const limited = await enforceRateLimit(request, 'resolve') + if (limited !== undefined) return limited + + const parsed = Body.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_body', message: 'Check the account details and try again.' }, + { status: 400 } + ) + } + const { currency, institution, accountIdentifier } = parsed.data + if (!isCashoutCurrency(currency) || !liveCurrencies().includes(currency)) { + return NextResponse.json( + { error: 'unknown_currency', message: 'Cash-outs do not pay out in that currency.' }, + { status: 400 } + ) + } + + // An account this requester has already used was looked up then, and asking + // again would spend a lookup to learn the same name. + const known = await getWeaveOrderRepo() + .then((repo) => repo.findHolder(guard.email, currency, institution, accountIdentifier)) + .catch(() => undefined) + if (known !== undefined) return NextResponse.json({ accountName: known }) + + const result = await createWeaveClient().verifyAccount({ + institution, + accountIdentifier, + currency, + }) + if (!result.ok) { + const status = result.code === 'rejected' ? 422 : 503 + return NextResponse.json({ error: result.code, message: result.message }, { status }) + } + return NextResponse.json({ accountName: result.value.accountName }) +} diff --git a/apps/dapp/app/api/cashout/webhook/route.ts b/apps/dapp/app/api/cashout/webhook/route.ts new file mode 100644 index 00000000..02944fbf --- /dev/null +++ b/apps/dapp/app/api/cashout/webhook/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { reportError } from '../../../../lib/server/report' +import { getWeaveOrderRepo } from '../../../../lib/weave/orders' +import { nextStatus } from '../../../../lib/weave/status' +import { verifyWeaveSignature } from '../../../../lib/weave/webhook' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const Event = z.object({ + event: z.string().optional(), + data: z.object({ id: z.string().min(1), status: z.string().optional() }), +}) + +const FROM_EVENT: Readonly> = { + 'order.completed': 'completed', + 'order.failed': 'failed', +} + +/** + * Order events from Weave. + * + * Verified over the bytes as they arrived, before anything is read from them, + * because re-serialising changes what the signature covers. Weave retries on any + * non-2xx, so an event that can never be applied gets a 200: an unknown order, a + * replay, or one with no order id. A record that could not be written gets a 500, + * since a retry can fix that. + */ +export async function POST(request: Request): Promise { + const raw = Buffer.from(await request.arrayBuffer()) + const verified = verifyWeaveSignature( + raw, + request.headers.get('x-weave-signature'), + request.headers.get('x-weave-timestamp'), + process.env['WEAVE_WEBHOOK_SECRET'] ?? '' + ) + if (!verified) return NextResponse.json({ error: 'invalid_signature' }, { status: 401 }) + + let body: unknown + try { + body = JSON.parse(raw.toString('utf8')) + } catch { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + const parsed = Event.safeParse(body) + if (!parsed.success) return NextResponse.json({ ok: true }) + + const { event, data } = parsed.data + // The status text is what the order is. The event name is only a fallback. + const provider = data.status ?? (event === undefined ? undefined : FROM_EVENT[event]) + if (provider === undefined) return NextResponse.json({ ok: true }) + + try { + const repo = await getWeaveOrderRepo() + const row = await repo.getByProviderOrder(data.id) + if (row === undefined) return NextResponse.json({ ok: true }) + + const status = nextStatus(row.status, provider) + if (status !== row.status) await repo.setStatus(data.id, status, provider) + return NextResponse.json({ ok: true }) + } catch (e) { + reportError('cashout/webhook', e, { event }) + return NextResponse.json({ error: 'unavailable' }, { status: 500 }) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts new file mode 100644 index 00000000..bcbc7722 --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { prepareChainAction } from '../../../../../lib/chains/prepare' +import { chainModule } from '../../../../../lib/chains/registry' +import { ChainActionRefused } from '../../../../../lib/chains/types' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' +import { isWalletAddress } from '../../../../../lib/wallet/address' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const Body = z.object({ + account: z.string().min(1).max(128), + request: z.object({ + kind: z.enum(['swap', 'supply', 'stake']), + amount: z + .string() + .regex(/^\d+(\.\d+)?$/) + .max(40), + tokenIn: z.string().min(1).max(16), + tokenOut: z.string().min(1).max(16).optional(), + venue: z.string().min(1).max(40).optional(), + }), +}) + +/** Builds an action for the wallet to sign. Nothing moves until the user signs what comes back. */ +export async function POST( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'build') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const parsed = Body.safeParse(await request.json().catch(() => undefined)) + if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + if (!isWalletAddress(parsed.data.account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + try { + const { kind, amount, tokenIn, tokenOut, venue } = parsed.data.request + const prepared = await prepareChainAction( + chainApi, + { + kind, + amount, + tokenIn, + ...(tokenOut !== undefined ? { tokenOut } : {}), + ...(venue !== undefined ? { venue } : {}), + }, + parsed.data.account + ) + return NextResponse.json({ prepared }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ error: e.message }, { status: 422 }) + } + reportError('chain/prepare', e, { chain: chainApi.chain }) + return NextResponse.json( + { error: 'This could not be built right now. Nothing was signed or sent.' }, + { status: 502 } + ) + } +} diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts new file mode 100644 index 00000000..0d58363b --- /dev/null +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { chainModule } from '../../../../../lib/chains/registry' +import { ChainActionRefused, type SigningPayload } from '../../../../../lib/chains/types' +import { logExecution } from '../../../../../lib/server/analytics' +import { enforceRateLimit } from '../../../../../lib/server/rate-limit' +import { reportError } from '../../../../../lib/server/report' +import { isWalletAddress } from '../../../../../lib/wallet/address' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const Signed = z.discriminatedUnion('family', [ + z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }), + z.object({ + family: z.literal('sui'), + transaction: z.string().min(1).max(262144), + signature: z.string().min(1).max(1024), + }), + z.object({ + family: z.literal('near'), + hashes: z.array(z.string().min(1).max(128)).min(1).max(8), + }), +]) + +const Body = z.object({ + account: z.string().min(1).max(128), + venueId: z.string().min(1).max(40), + signed: Signed, + context: z.record(z.string().max(2048)).optional(), +}) + +/** + * Re-checks signed bytes before sending them. Signed bytes come from the browser, + * so what they do is checked again here and never taken from what was prepared. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ chain: string }> } +): Promise { + const limited = await enforceRateLimit(request, 'submit') + if (limited !== undefined) return limited + + const chainApi = chainModule((await params).chain) + if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 }) + + const parsed = Body.safeParse(await request.json().catch(() => undefined)) + if (!parsed.success) return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + const { account, venueId, signed, context } = parsed.data + if (!isWalletAddress(account)) { + return NextResponse.json({ error: 'invalid_account' }, { status: 400 }) + } + + const venue = chainApi.venues.find((v) => v.id === venueId) + if (venue === undefined) return NextResponse.json({ error: 'unknown_venue' }, { status: 404 }) + if (signed.family !== chainApi.chain) { + return NextResponse.json({ error: 'wrong_chain' }, { status: 400 }) + } + + try { + // A NEAR wallet has already sent its transactions, so there are no bytes left to check. + if (signed.family !== 'near') { + const payload: SigningPayload = + signed.family === 'solana' + ? { family: 'solana', transaction: signed.signedTransaction } + : { family: 'sui', transaction: signed.transaction } + venue.assertSafe(payload, account) + } + + const input = { account, venueId, signed, ...(context !== undefined ? { context } : {}) } + const result = await (venue.submit ?? chainApi.submit)(input) + await logExecution({ + kind: chainApi.chain, + account, + feeSponsored: false, + network: 'mainnet', + // Sent with no outcome yet is its own thing in the numbers, not an unknown failure. + result: !result.ok && result.pending === true ? { ...result, reason: 'pending' } : result, + }) + return NextResponse.json(result, { + status: result.ok ? 200 : result.pending === true ? 202 : 422, + }) + } catch (e) { + if (e instanceof ChainActionRefused) { + return NextResponse.json({ ok: false, error: e.message }, { status: 422 }) + } + reportError('chain/submit', e, { chain: chainApi.chain }) + return NextResponse.json( + { + ok: false, + error: + "This could not be confirmed. Check your wallet's activity before trying again, in case it went through.", + }, + { status: 502 } + ) + } +} diff --git a/apps/dapp/app/api/prices/native/route.ts b/apps/dapp/app/api/prices/native/route.ts new file mode 100644 index 00000000..5e6c9194 --- /dev/null +++ b/apps/dapp/app/api/prices/native/route.ts @@ -0,0 +1,14 @@ +import { isChainSlug } from '@intent/config' +import { NextResponse } from 'next/server' + +import { fetchNativeCoinPrice } from '../../../../lib/prices/native-coin' + +/** The price of a chain's own coin. A null price is an ordinary answer: nothing fresh to show. */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: Request): Promise { + const chain = new URL(request.url).searchParams.get('chain') ?? '' + if (!isChainSlug(chain)) return NextResponse.json({ error: 'unknown chain' }, { status: 400 }) + return NextResponse.json({ price: (await fetchNativeCoinPrice(chain)) ?? null }) +} diff --git a/apps/dapp/app/globals.css b/apps/dapp/app/globals.css index 39fbf4ea..539f4519 100644 --- a/apps/dapp/app/globals.css +++ b/apps/dapp/app/globals.css @@ -1,175 +1,176 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 60 20% 95.7%; - --foreground: 60 5% 10%; - --card: 60 20% 95.7%; - --card-foreground: 60 5% 10%; - --border: 60 10% 85%; - --input: 60 10% 85%; - --ring: 240 4.9% 83.9%; - /* One purple for the whole app: controls, the page glow and the chat all - use this value or a tint of it. `--glow` is kept as its own token only - so the canvas can be tuned separately; it holds the same colour, because - a brighter glow read as a different, bluer purple on the dark page. */ - --brand: 237 45% 45%; - --brand-foreground: 0 0% 98%; - --glow: 237 45% 45%; - --accent: 189 94% 43%; - --accent-foreground: 0 0% 98%; - --muted: 60 10% 90%; - --muted-foreground: 60 5% 40%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --success: 142 76% 36%; - --warning: 38 92% 50%; - --surface-base: 60 20% 95.7%; - --surface-elevated: 60 20% 95.7%; - --surface-card: 60 20% 95.7%; - --radius: 0.4rem; - /* Chat bubbles: the user's in a light brand purple, the agents' in white. */ - --bubble-me: 237 45% 91%; - --bubble-me-foreground: 237 45% 30%; - --bubble-agent: 0 0% 100%; - --bubble-agent-foreground: 60 5% 10%; - --bubble-agent-border: 237 30% 92%; - } - - /* - * Dark: black, grey and ash. - * - * Built as a neutral ladder rather than an inverted light theme. The - * background is near-black and each surface above it is a step lighter, so - * depth comes from value alone and nothing has to be tinted to read as - * raised. - * - * The brand purple is deliberately unchanged. It is the only saturated - * colour in either theme, which is what makes a button unmistakably a - * button — recolouring it per theme would cost that. - * - * The one exception to "value alone" is the page canvas: see `.app-canvas` - * below, which lays a brand-tinted glow and film grain under the content in - * both themes. Surfaces above it stay neutral, so they still read as raised. - * - * Only tokens are redefined here. Every component reads these, so a - * component that hardcodes a colour is a bug this file cannot fix. - */ - .dark { - --background: 0 0% 6%; - --foreground: 0 0% 93%; - - /* One step above the page, so a card reads as raised without a border. */ - --card: 0 0% 9%; - --card-foreground: 0 0% 93%; - - /* Low contrast against the card: a visible edge, not a drawn line. */ - --border: 0 0% 18%; - --input: 0 0% 18%; - --ring: 0 0% 32%; - - /* Unchanged from light. The one colour that carries meaning. */ - --brand: 237 45% 45%; - --brand-foreground: 0 0% 98%; - --glow: 237 45% 45%; - --accent: 189 94% 43%; - --accent-foreground: 0 0% 98%; - - --muted: 0 0% 14%; - /* Ash. Around 4.5:1 on the page background — readable as secondary text - rather than as something disabled. */ - --muted-foreground: 0 0% 64%; - - /* Lighter than the light-theme red, which is too dark to read here. */ - --destructive: 0 72% 51%; - --destructive-foreground: 0 0% 98%; - --success: 142 69% 45%; - --warning: 38 92% 55%; - - --surface-base: 0 0% 6%; - --surface-elevated: 0 0% 11%; - --surface-card: 0 0% 9%; - - /* Agents go purple-black rather than grey, so a reply still reads as the - brand's side of the thread. */ - --bubble-me: 237 45% 82%; - --bubble-me-foreground: 237 45% 22%; - --bubble-agent: 238 35% 29%; /* #303263 */ - --bubble-agent-foreground: 237 45% 96%; - --bubble-agent-border: 238 30% 37%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground font-sans; - } - ::selection { - @apply bg-brand/20 text-brand; - } -} - -/* - * Page canvas: a brand glow with film grain. - * - * Applied to the column that holds the header and the scrolling page. The - * column itself never scrolls, so the glow stays put and the content moves - * over it. Cards and inputs are opaque, so the tint shows only in the gaps - * between them. - * - * The grain is an SVG turbulence tile in a pseudo-element rather than a second - * background layer, so its strength is one `opacity` value. `isolation` keeps - * the negative z-index inside this element; without it the grain would drop - * behind the page. - * - * One strength for both themes. Light used to run gentler on the theory that - * the same alpha reads louder on off-white; in practice it read as a faint - * wash next to the dark canvas, and the brief is that the glow and grain be - * just as present in light. The grain darkens a light page and brightens a - * dark one, which is the only difference left. - */ -.app-canvas { - position: relative; - isolation: isolate; - background-color: hsl(var(--background)); - background-image: - radial-gradient( - ellipse 90% 70% at 50% -15%, - hsl(var(--glow) / 0.6) 0%, - hsl(var(--glow) / 0.28) 35%, - hsl(var(--glow) / 0) 72% - ), - radial-gradient(ellipse 60% 40% at 50% 0%, hsl(var(--glow) / 0.25), transparent 60%); -} - -.app-canvas::before { - content: ''; - position: absolute; - inset: 0; - z-index: -1; - pointer-events: none; - opacity: 0.11; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E"); - background-size: 240px 240px; -} - -/* Scrolls without drawing a bar, for panes whose content already shows where it ends. */ -.scrollbar-none { - scrollbar-width: none; -} -.scrollbar-none::-webkit-scrollbar { - display: none; -} - -/* RainbowKit connect button: normal weight to match the flat, no-bold rule. */ -[data-rk] button { - font-weight: 400 !important; -} +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 60 20% 95.7%; + --foreground: 60 5% 10%; + --card: 60 20% 95.7%; + --card-foreground: 60 5% 10%; + --border: 60 10% 85%; + --input: 60 10% 85%; + --ring: 240 4.9% 83.9%; + /* One purple for the whole app: controls, the page glow and the chat all + use this value or a tint of it. `--glow` is kept as its own token only + so the canvas can be tuned separately; it holds the same colour, because + a brighter glow read as a different, bluer purple on the dark page. */ + --brand: 237 45% 45%; + --brand-foreground: 0 0% 98%; + --glow: 237 45% 45%; + --accent: 189 94% 43%; + --accent-foreground: 0 0% 98%; + --muted: 60 10% 90%; + --muted-foreground: 60 5% 40%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --success: 142 76% 36%; + --warning: 38 92% 50%; + --surface-base: 60 20% 95.7%; + --surface-elevated: 60 20% 95.7%; + --surface-card: 60 20% 95.7%; + --radius: 0.4rem; + /* Chat bubbles: the user's in a light brand purple, the agents' in white. */ + --bubble-me: 237 45% 91%; + --bubble-me-foreground: 237 45% 30%; + --bubble-agent: 0 0% 100%; + --bubble-agent-foreground: 60 5% 10%; + --bubble-agent-border: 237 30% 92%; + } + + /* + * Dark: black, grey and ash. + * + * Built as a neutral ladder rather than an inverted light theme. The + * background is near-black and each surface above it is a step lighter, so + * depth comes from value alone and nothing has to be tinted to read as + * raised. + * + * The brand purple is deliberately unchanged. It is the only saturated + * colour in either theme, which is what makes a button unmistakably a + * button — recolouring it per theme would cost that. + * + * The one exception to "value alone" is the page canvas: see `.app-canvas` + * below, which lays a brand-tinted glow and film grain under the content in + * both themes. Surfaces above it stay neutral, so they still read as raised. + * + * Only tokens are redefined here. Every component reads these, so a + * component that hardcodes a colour is a bug this file cannot fix. + */ + .dark { + --background: 0 0% 6%; + --foreground: 0 0% 93%; + + /* One step above the page, so a card reads as raised without a border. */ + --card: 0 0% 9%; + --card-foreground: 0 0% 93%; + + /* Low contrast against the card: a visible edge, not a drawn line. */ + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 0 0% 32%; + + /* Unchanged from light. The one colour that carries meaning. */ + --brand: 237 45% 45%; + --brand-foreground: 0 0% 98%; + --glow: 237 45% 45%; + --accent: 189 94% 43%; + --accent-foreground: 0 0% 98%; + + --muted: 0 0% 14%; + /* Ash. Around 4.5:1 on the page background — readable as secondary text + rather than as something disabled. */ + --muted-foreground: 0 0% 64%; + + /* Lighter than the light-theme red, which is too dark to read here. */ + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 98%; + --success: 142 69% 45%; + --warning: 38 92% 55%; + + --surface-base: 0 0% 6%; + --surface-elevated: 0 0% 11%; + --surface-card: 0 0% 9%; + + /* Agents go purple-black rather than grey, so a reply still reads as the + brand's side of the thread. */ + --bubble-me: 237 45% 82%; + --bubble-me-foreground: 237 45% 22%; + --bubble-agent: 238 35% 29%; /* #303263 */ + --bubble-agent-foreground: 237 45% 96%; + --bubble-agent-border: 238 30% 37%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground font-sans; + } + ::selection { + @apply bg-brand/20 text-brand; + } +} + +/* + * Page canvas: a brand glow with film grain. + * + * Applied to the column that holds the header and the scrolling page. The + * column itself never scrolls, so the glow stays put and the content moves + * over it. Cards and inputs are opaque, so the tint shows only in the gaps + * between them. + * + * The grain is an SVG turbulence tile in a pseudo-element rather than a second + * background layer, so its strength is one `opacity` value. `isolation` keeps + * the negative z-index inside this element; without it the grain would drop + * behind the page. + * + * One strength for both themes. Light used to run gentler on the theory that + * the same alpha reads louder on off-white; in practice it read as a faint + * wash next to the dark canvas, and the brief is that the glow and grain be + * just as present in light. The grain darkens a light page and brightens a + * dark one, which is the only difference left. + */ +.app-canvas { + position: relative; + isolation: isolate; + background-color: hsl(var(--background)); + background-image: + radial-gradient( + ellipse 90% 70% at 50% -15%, + hsl(var(--glow) / 0.6) 0%, + hsl(var(--glow) / 0.28) 35%, + hsl(var(--glow) / 0) 72% + ), + radial-gradient(ellipse 60% 40% at 50% 0%, hsl(var(--glow) / 0.25), transparent 60%); +} + +.app-canvas::before, +.showcase-panel::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + opacity: 0.11; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E"); + background-size: 240px 240px; +} + +/* Scrolls without drawing a bar, for panes whose content already shows where it ends. */ +.scrollbar-none { + scrollbar-width: none; +} +.scrollbar-none::-webkit-scrollbar { + display: none; +} + +/* RainbowKit connect button: normal weight to match the flat, no-bold rule. */ +[data-rk] button { + font-weight: 400 !important; +} /* Admin charts: five categorical slots, validated per theme. Slot 1 is the brand violet. */ :root { @@ -187,3 +188,13 @@ --series-4: #c98500; --series-5: #d55181; } + +/* The connect page's slide timer. Its end is what moves to the next slide. */ +@keyframes showcase-progress { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} diff --git a/apps/dapp/app/layout.tsx b/apps/dapp/app/layout.tsx index 0a6378a8..f797b320 100644 --- a/apps/dapp/app/layout.tsx +++ b/apps/dapp/app/layout.tsx @@ -1,42 +1,44 @@ -import type { Metadata } from 'next' -import { Inter, JetBrains_Mono, Playfair_Display, Cormorant } from 'next/font/google' - -import { RootProviders } from '../providers/root' - -import './globals.css' - -const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }) -const playfair = Playfair_Display({ - subsets: ['latin'], - style: ['normal', 'italic'], - variable: '--font-playfair', - display: 'swap', -}) -const cormorant = Cormorant({ - subsets: ['latin'], - style: ['normal', 'italic'], - variable: '--font-cormorant', - display: 'swap', -}) -const geistMono = JetBrains_Mono({ - subsets: ['latin'], - variable: '--font-geist-mono', - display: 'swap', -}) - -export const metadata: Metadata = { - title: { default: 'Intent', template: '%s · Intent' }, - description: 'Submit intents. Watch agents compete. Settle on-chain.', -} - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ) -} +import type { Metadata } from 'next' +import { Inter, JetBrains_Mono, Playfair_Display, Cormorant } from 'next/font/google' + +import { RootProviders } from '../providers/root' + +import './globals.css' + +const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }) +const playfair = Playfair_Display({ + subsets: ['latin'], + style: ['normal', 'italic'], + variable: '--font-playfair', + display: 'swap', +}) +const cormorant = Cormorant({ + subsets: ['latin'], + style: ['normal', 'italic'], + variable: '--font-cormorant', + display: 'swap', +}) +const geistMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-geist-mono', + display: 'swap', +}) + +export const metadata: Metadata = { + title: { default: 'Intent', template: '%s · Intent' }, + description: 'Submit intents. Watch agents compete. Settle on-chain.', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {/* Browser extensions such as ColorZilla add attributes to before React loads. */} + + {children} + + + ) +} diff --git a/apps/dapp/app/page.tsx b/apps/dapp/app/page.tsx new file mode 100644 index 00000000..b0319c6b --- /dev/null +++ b/apps/dapp/app/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react' + +import { ConnectHome } from '../components/connect/connect-home' + +/** The app's front door: connect a wallet, and the chain it is on goes into the address. */ +export default function HomePage(): JSX.Element { + return ( + }> + + + ) +} diff --git a/apps/dapp/components/apps/venue-card.tsx b/apps/dapp/components/apps/venue-card.tsx index 1400dd5e..099df6ed 100644 --- a/apps/dapp/components/apps/venue-card.tsx +++ b/apps/dapp/components/apps/venue-card.tsx @@ -6,7 +6,13 @@ import { ArrowUpRight, BadgeCheck } from 'lucide-react' import Image from 'next/image' import { useEffect, useRef, useState } from 'react' +import { chainMarkFor } from '../../lib/chain-mark' +import { venueDetail } from '../../lib/venues' + export const categoryLabel: Record = { + savings: 'Savings', + staking: 'Staking', + payments: 'Payments', swap: 'Swap', aggregator: 'Aggregator', orderbook: 'Order book', @@ -19,13 +25,6 @@ export const categoryLabel: Record = { infrastructure: 'Network', } -function chainSlug(chain: string): string { - return chain - .toLowerCase() - .replace(/\s*chain$/, '') - .trim() -} - /** * The verified mark, for a venue intents can actually be signed through. * @@ -153,7 +152,7 @@ export function VenueCard({ target="_blank" rel="noopener noreferrer" // What the app can actually do here, for anyone who wants the detail. - title={venue.capability ?? venue.bestFor} + title={venueDetail(venue, notHere)} className={cn( 'bg-bubble-agent text-bubble-agent-foreground border-bubble-agent-border group relative isolate flex flex-col justify-between gap-4 overflow-hidden rounded-3xl border p-4 transition-all duration-200 sm:aspect-square', live @@ -170,27 +169,26 @@ export function VenueCard({
{showChains ? (
- {venue.chains - .slice(0, 3) - .map((chain) => - chainSlug(chain) === 'arc' ? ( - - ) : ( - {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} + +
    + + + {formatUsdc(order.amount)} USDC + + + + {bank !== undefined ? : null} + {formatPayout(order.estimatedDest, order.currency)} + +
    + +
    +
    +
    Account holder
    +
    + {isRealHolderName(order.accountName) ? order.accountName : 'Not confirmed'} +
    +
    +
    +
    {isNaira ? 'Bank' : 'Paid to'}
    +
    + {order.institutionName} {order.accountMask} +
    +
    +
    + + {href !== undefined && isCashoutChain(order.chain) ? ( + + View payment on {explorerName(order.chain)} + + + ) : null} + + {running ? ( +

    + Your USDC has left or is leaving your wallet. {DO_NOT_RESEND} +

    + ) : null} +
    + ) +} + +function BeforeYouSign({ + address, + expiresAt, +}: { + address: string + expiresAt?: string | undefined +}): JSX.Element { + const [deadline] = useState(() => { + const stated = expiresAt !== undefined ? Date.parse(expiresAt) : Number.NaN + return Number.isNaN(stated) ? Date.now() + PAY_WINDOW_MS : stated + }) + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, []) + + const left = Math.max(0, deadline - now) + const expired = left === 0 + const mm = Math.floor(left / 60_000) + const ss = String(Math.floor((left % 60_000) / 1000)).padStart(2, '0') + + return ( +
    +
    + + + +
    + {expired ? 'Time is up' : 'Pay within'} + + {expired + ? 'This payout will be cancelled. Use Edit to start a new one.' + : 'Unpaid orders are cancelled. Nothing is taken.'} + +
    + + {mm}:{ss} + +
    + +
    + + + +
    + Match the address in your wallet + + + {address.slice(0, 6)} + + ··· + + {address.slice(-6)} + + + + A one-time deposit address made for this payout, so a wallet security scan may flag it + as new. + +
    +
    +
    + ) +} + +function Message({ children }: { children: ReactNode }): JSX.Element { + return
    {children}
    +} + +const BACK_BUTTON = + 'border-bubble-agent-foreground/15 hover:bg-bubble-agent-foreground/5 w-fit rounded-full border px-4 py-2 text-sm transition-colors' + +/** + * The account number is held here and in the requests that check and use it, and + * nowhere else: not in the chat, its history or any log. + */ +export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | null { + const { slug, descriptor } = useChain() + const { address, isWrongNetwork, switchNetwork, isSwitching } = useWallet() + const seed = cashout.session?.seed + + const [amount, setAmount] = useState(seed?.amount ?? '') + const [settledAmount, setSettledAmount] = useState(seed?.amount ?? '') + const [bankCode, setBankCode] = useState('') + const [accountNumber, setAccountNumber] = useState('') + const [settledAccount, setSettledAccount] = useState('') + + useEffect(() => { + const id = window.setTimeout(() => setSettledAmount(amount), 400) + return () => window.clearTimeout(id) + }, [amount]) + useEffect(() => { + const id = window.setTimeout(() => setSettledAccount(accountNumber), 500) + return () => window.clearTimeout(id) + }, [accountNumber]) + + const options = useQuery({ + queryKey: ['cashout-options', slug], + queryFn: () => fetchCashoutCurrencies(slug), + staleTime: 60_000, + retry: false, + }) + const currencies = options.data + const seedAvailable = + seed?.currency !== undefined && currencies?.some((c) => c.code === seed.currency) === true + const currency = seedAvailable + ? (seed?.currency as string) + : (currencies?.find((c) => c.code === 'NGN')?.code ?? currencies?.[0]?.code ?? '') + const corridor = currencies?.find((c) => c.code === currency) + const isNaira = currency === 'NGN' + + const institutions = useQuery({ + queryKey: ['cashout-institutions', currency], + queryFn: async () => { + const res = await walletFetch( + `/api/cashout/institutions?currency=${encodeURIComponent(currency)}` + ) + const body = (await res.json()) as { institutions?: CashoutInstitution[]; message?: string } + if (!res.ok || body.institutions === undefined) { + throw new Error(body.message ?? 'Could not load the banks.') + } + return body.institutions + }, + enabled: currency !== '', + staleTime: 300_000, + retry: false, + }) + + // The same list the naira form reads, for the same logos on the same banks. + const nairaBanks = useQuery({ + queryKey: ['naira-banks'], + queryFn: async () => + ((await (await fetch('/api/naira/banks')).json()) as { banks: Bank[] }).banks, + staleTime: Infinity, + enabled: isNaira, + }) + + const banks = useMemo( + () => + institutions.data === undefined + ? undefined + : withBankLogos(institutions.data, isNaira ? (nairaBanks.data ?? []) : [], currency), + [institutions.data, nairaBanks.data, isNaira, currency] + ) + + const balanceQuery = useQuery({ + queryKey: ['cashout-balance', slug, address], + queryFn: async () => { + const params = new URLSearchParams({ chain: slug, account: address as string }) + const res = await walletFetch(`/api/cashout/balance?${params.toString()}`) + const body = (await res.json()) as { balance?: string } + if (!res.ok || body.balance === undefined) throw new Error('No balance.') + return body.balance + }, + enabled: address !== undefined, + staleTime: 15_000, + retry: false, + }) + const balance = balanceQuery.data + + const quoteReady = currency !== '' && isValidAmount(settledAmount) + const quote = useQuery({ + queryKey: ['cashout-quote', slug, currency, settledAmount], + queryFn: async () => { + const params = new URLSearchParams({ chain: slug, currency, amount: settledAmount }) + const res = await walletFetch(`/api/cashout/quote?${params.toString()}`) + const body = (await res.json()) as Partial & { message?: string } + if (!res.ok || body.destAmount === undefined) { + throw new Error(body.message ?? 'No estimate is available for this amount.') + } + return body as CashoutQuote + }, + enabled: quoteReady, + staleTime: 20_000, + retry: false, + }) + + const picked = institutions.data?.find((i) => i.code === bankCode) + const hint = accountHint(picked?.type, currency) + const accountToCheck = isNaira ? accountNumber : settledAccount + const accountReady = + bankCode !== '' && (isNaira ? /^\d{10}$/.test(accountToCheck) : accountToCheck.length >= 6) + const holder = useQuery({ + queryKey: ['cashout-holder', currency, bankCode, accountToCheck], + queryFn: async () => { + const res = await walletFetch('/api/cashout/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + currency, + institution: bankCode, + accountIdentifier: accountToCheck, + }), + }) + const body = (await res.json()) as { accountName?: string; message?: string } + if (!res.ok || body.accountName === undefined) { + throw new Error(body.message ?? 'That account could not be verified.') + } + return body.accountName + }, + enabled: accountReady, + staleTime: Infinity, + retry: false, + }) + + const { phase, order } = cashout + const reviewBank = banks?.find((b) => b.code === bankCode || b.name === order?.institutionName) + + if (cashout.session === null) return null + + if ((phase === 'tracking' || phase === 'pending') && order !== undefined) { + return ( + + ) + } + + if (phase === 'check' && order !== undefined) { + return ( +
    + +

    + {CHECK_WALLET} +

    +

    + If your wallet shows it was sent, {DO_NOT_RESEND.toLowerCase()} This page keeps following + the order. +

    + +
    + ) + } + + if ( + (phase === 'review' || phase === 'signing' || phase === 'submitting') && + order !== undefined + ) { + const busy = phase !== 'review' + const destination = Number(order.estimatedDest) + return ( +
    + + +
    + You send + {formatUsdc(order.amount)} USDC + {reviewBank !== undefined ? ( + + ) : ( + + + + )} + + {isRealHolderName(order.accountName) ? order.accountName : 'This account'} receives + + + ≈ {formatPayout(destination, order.currency)} + +
    + +
    + {( + [ + [ + 'Rate', + `≈ ${formatPayout(destination / Number(order.amount), order.currency)} per USDC`, + ], + [ + 'Account holder', + isRealHolderName(order.accountName) ? order.accountName : 'Not confirmed', + ], + [isNaira ? 'Bank' : 'Paid to', `${order.institutionName} ${order.accountMask}`], + ] as const + ).map(([k, v]) => ( +
    +
    {k}
    +
    {v}
    +
    + ))} + +
    + + + + {cashout.error !== undefined ? ( +

    + {cashout.error} +

    + ) : null} + +
    + + +
    +
    + ) + } + + if (phase === 'failed') { + return ( + +

    + + {cashout.error ?? 'The payout did not go through.'} +

    + +
    + ) + } + + if (options.isLoading) { + return ( +
    + + Checking where you can cash out… +
    + ) + } + + if (currencies === undefined || currencies.length === 0) { + return ( + +

    {NO_CASHOUTS}

    + +
    + ) + } + + const send = Number(amount) + const maxAmount = balance !== undefined ? Number(balance) : undefined + const overBalance = maxAmount !== undefined && send > maxAmount + const estimate = quoteReady && quote.data !== undefined ? quote.data : undefined + const canReview = + isValidAmount(amount) && estimate !== undefined && !overBalance && holder.data !== undefined + const bankName = banks?.find((b) => b.code === bankCode)?.name + const creating = phase === 'creating' + + return ( +
    + + + {seed?.currency !== undefined && !seedAvailable ? ( +

    + {seed.currency} isn't available here, so this pays out in {currency}. +

    + ) : null} + +
    +
    +
    + + {balance !== undefined ? ( + + Available {Number(balance).toLocaleString('en-US', { maximumFractionDigits: 2 })} + {maxAmount !== undefined && maxAmount > 0 ? ( + + ) : null} + + ) : null} +
    +
    + setAmount(e.target.value.replace(/[^\d.]/g, ''))} + placeholder="0.00" + className="placeholder:text-muted-foreground/50 min-w-0 flex-1 bg-transparent text-2xl font-semibold tabular-nums outline-none" + /> + + + USDC + +
    +
    + {estimate !== undefined ? ( + + + ≈{' '} + + {formatPayout(estimate.destAmount, currency)} + + + + Estimate. You see the exact amount before you sign. + + + ) : ( + + {quote.isError && quoteReady + ? (quote.error as Error).message + : 'Enter an amount to see what the bank receives.'} + + )} +
    +
    + {overBalance ? ( +

    That is more USDC than the account holds.

    + ) : null} +
    + +
    +
    + + + {institutions.isError ? ( +

    {(institutions.error as Error).message}

    + ) : null} +
    +
    + +
    + + setAccountNumber( + isNaira ? e.target.value.replace(/\D/g, '') : cleanIdentifier(e.target.value) + ) + } + placeholder={isNaira ? '0123456789' : hint.placeholder} + className="placeholder:text-muted-foreground/50 min-w-0 flex-1 bg-transparent text-sm tabular-nums tracking-[0.15em] outline-none" + /> +
    +
    + {holder.isFetching ? ( + + + Checking with the bank… + + ) : holder.data !== undefined && accountReady ? ( + + {isRealHolderName(holder.data) ? ( + <> + {titleCase(holder.data)} + + + ) : ( + + The bank can't confirm a name here, so check this number carefully. + + )} + + ) : holder.isError ? ( + {(holder.error as Error).message} + ) : null} +
    +
    +
    + + +
    + ) +} diff --git a/apps/dapp/components/intents/cashout-currency-question.tsx b/apps/dapp/components/intents/cashout-currency-question.tsx new file mode 100644 index 00000000..e42460b9 --- /dev/null +++ b/apps/dapp/components/intents/cashout-currency-question.tsx @@ -0,0 +1,63 @@ +'use client' + +import { cn } from '@intent/ui' + +import { CURRENCY_QUESTION, type CashoutCurrency } from '../../lib/weave/cashout-view' +import { agentBubble, userBubble } from './bubble' + +export interface CashoutQuestionState { + amount?: string + choices: CashoutCurrency[] + /** What the person answered. Set once, and the question stays in the thread as asked. */ + answer?: string + /** An answer that couldn't be used, and what was said back. */ + retry?: { said: string; reply: string } +} + +/** "Which currency?", answered by tapping one or by typing it in the composer. */ +export function CashoutCurrencyQuestion({ + question, + onChoose, +}: { + question: CashoutQuestionState + onChoose: (currency: CashoutCurrency) => void +}): JSX.Element { + const open = question.answer === undefined + return ( + <> +
    +
    {CURRENCY_QUESTION}
    +
    + {question.retry !== undefined ? ( + <> +
    +
    {question.retry.said}
    +
    +
    +
    {question.retry.reply}
    +
    + + ) : null} + {open ? ( +
    + {question.choices.map((choice) => ( + + ))} +
    + ) : ( +
    +
    {question.answer}
    +
    + )} + + ) +} diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx new file mode 100644 index 00000000..9011730c --- /dev/null +++ b/apps/dapp/components/intents/chain-action-confirm.tsx @@ -0,0 +1,115 @@ +'use client' + +import { Button, Card } from '@intent/ui' +import { ExternalLink, Loader2 } from 'lucide-react' + +import type { ChainAction } from '../../hooks/use-chain-action' + +/** Review, sign and result for an action on Solana, Sui or NEAR. */ +export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element { + const { phase, prepared, error, hash, explorerUrl } = action + const busy = phase === 'signing' || phase === 'submitting' + + return ( + + {phase === 'building' ? ( +

    +

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

    + {error} +

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