Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 17 additions & 1 deletion apps/dapp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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=
16 changes: 14 additions & 2 deletions apps/dapp/app/[chain]/apps/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
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<JSX.Element> {
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()]
return (
<div className="flex flex-col gap-6 p-6">
<PageHeader
title="Apps"
description="The liquidity venues your intents can reach. Agents compete across these to fill your order — you never pick one yourself."
description={
racing
? 'The liquidity venues your intents can reach. Agents compete across these to fill your order — you never pick one yourself.'
: 'The apps your intents can reach on this chain. Ask in chat, or name the one you want.'
}
/>
<VenueGrid availableIds={availableIds} />
</div>
Expand Down
8 changes: 4 additions & 4 deletions apps/dapp/app/[chain]/intents/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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. */}
<p className="text-foreground flex items-center gap-1">
{intent.tokenIn} on {descriptor.name}{' '}
<Badge variant="outline">{descriptor.networkLabel}</Badge>
<p className="text-foreground">
{intent.tokenIn} on {chainTitle(descriptor.name, descriptor.networkLabel)}
</p>
</div>
</div>
Expand Down
12 changes: 11 additions & 1 deletion apps/dapp/app/[chain]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 (
<ChainProvider segment={chain}>
<AppShell tradeCapUsd={cap}>{children}</AppShell>
<WalletGate bypass={bypass}>
<AppShell tradeCapUsd={cap}>{children}</AppShell>
</WalletGate>
</ChainProvider>
)
}
Expand Down
10 changes: 10 additions & 0 deletions apps/dapp/app/[chain]/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="status" aria-label="Loading" className="flex h-full items-center justify-center p-6">
<Loader2 className="text-muted-foreground h-5 w-5 motion-safe:animate-spin" />
</div>
)
}
45 changes: 45 additions & 0 deletions apps/dapp/app/api/cashout/balance/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 })
}
}
32 changes: 32 additions & 0 deletions apps/dapp/app/api/cashout/institutions/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 })
}
27 changes: 27 additions & 0 deletions apps/dapp/app/api/cashout/options/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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]),
})
}
74 changes: 74 additions & 0 deletions apps/dapp/app/api/cashout/orders/[id]/build/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 }
)
}
}
29 changes: 29 additions & 0 deletions apps/dapp/app/api/cashout/orders/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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) })
}
Loading
Loading