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
  •  
  •  
  •  
434 changes: 216 additions & 218 deletions apps/dapp/.env.example

Large diffs are not rendered by default.

14 changes: 3 additions & 11 deletions apps/dapp/app/[chain]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { notFound } from 'next/navigation'
import { WalletGate } from '../../components/connect/wallet-gate'
import { ChatHost } from '../../components/intents/chat-host'
import { AppShell } from '../../components/layout/app-shell'
import { tradeCapUsd } from '../../lib/server/trade-cap'
import { BetaChainNotice } from '../../components/layout/beta-chain-notice'
import { ChainProvider } from '../../providers/chain-provider'

/**
Expand All @@ -29,15 +29,6 @@ export default async function ChainLayout({
// showing a different chain than the URL names would be worse than an error.
if (parseChainSegment(chain) === undefined) notFound()

// A misconfigured cap must not take the whole frame down with it; the
// routes that enforce it report the problem where it matters.
let cap: number | undefined
try {
cap = tradeCapUsd()
} catch {
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 =
Expand All @@ -47,7 +38,8 @@ export default async function ChainLayout({
return (
<ChainProvider segment={chain}>
<WalletGate bypass={bypass}>
<AppShell tradeCapUsd={cap}>
<BetaChainNotice />
<AppShell>
<ChatHost key={chain}>{children}</ChatHost>
</AppShell>
</WalletGate>
Expand Down
6 changes: 3 additions & 3 deletions apps/dapp/app/api/agents/compete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { getRoster } from '../../../../lib/agents/registry'
import type { PlanContext } from '../../../../lib/agents/plan-scoring'
import { pickWinner, scoreProposals, unanimousChoice } from '../../../../lib/agents/scoring'
import {
assertAmountWithinCap,
assertValidAmount,
assertKnownTokens,
assertNativeReserve,
} from '../../../../lib/chains/prepare'
Expand Down Expand Up @@ -138,8 +138,8 @@ async function chainRaceFacts(
}
}
try {
const known = assertKnownTokens(chainApi, action)
await assertAmountWithinCap(chainApi, action, known)
assertKnownTokens(chainApi, action)
assertValidAmount(action)
} catch (e) {
if (e instanceof ChainActionRefused)
return { refusal: `${e.message} Nothing was signed or sent.` }
Expand Down
26 changes: 4 additions & 22 deletions apps/dapp/app/api/cashout/orders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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'
Expand All @@ -22,7 +21,7 @@ import {
toPublic,
} from '../../../../lib/weave/orders'
import { nextStatus } from '../../../../lib/weave/status'
import { isCashoutChain } from '../../../../lib/weave/types'
import { closedCashoutReply, isCashoutChain } from '../../../../lib/weave/types'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -56,23 +55,6 @@ function refusalFor(path: (string | number)[]): string {
}
}

/**
* 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<Response> {
const who = requesterOf(request)
Expand Down Expand Up @@ -112,6 +94,9 @@ export async function POST(request: Request): Promise<Response> {
if (!isCashoutChain(chain)) {
return refusal('unknown_chain', 'Cash-outs are not available on this chain.')
}
// The chat refuses this before any form opens. A page left open from before it did can still post.
const closed = closedCashoutReply(chain)
if (closed !== undefined) return refusal('chain_closed', closed, 422)
const onThisNetwork = chainRefusal(chain)
if (onThisNetwork !== undefined) return onThisNetwork

Expand Down Expand Up @@ -172,9 +157,6 @@ export async function POST(request: Request): Promise<Response> {
)
}

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)
Expand Down
81 changes: 81 additions & 0 deletions apps/dapp/app/api/chain/[chain]/positions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { isAddress as isSolanaAddress } from '@solana/kit'
import { NextResponse } from 'next/server'

import { NEAR_ACCOUNT } from '../../../../../lib/chains/near/account'
import type { PositionsReport, VenueHoldings } from '../../../../../lib/chains/positions-reply'
import { readPositionsBriefly } from '../../../../../lib/chains/prepare'
import { chainModule } from '../../../../../lib/chains/registry'
import type { ChainActionKind, ChainWithActions } from '../../../../../lib/chains/types'
import { enforceRateLimit } from '../../../../../lib/server/rate-limit'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 20

const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/
const SUI_ADDRESS = /^0x[0-9a-fA-F]{64}$/

/** The same shapes the balance readers hold to. Every read swallows its error, so a bad address has to stop here. */
function isAccountOn(chain: ChainWithActions, account: string): boolean {
switch (chain) {
case 'solana':
return isSolanaAddress(account)
case 'sui':
return SUI_ADDRESS.test(account)
case 'near':
return NEAR_ACCOUNT.test(account)
case 'avalanche':
case 'monad':
return EVM_ADDRESS.test(account)
}
}

/** A venue that only swaps or sends keeps nothing, so one with no reader is left out, not called unread. */
const HOLDS_MONEY: ReadonlySet<ChainActionKind> = new Set([
'supply',
'stake',
'withdraw',
'unstake',
'borrow',
'repay',
'stream',
'claim',
'cancel',
])

/** What the account holds at each venue on this chain, read when asked and never kept. */
export async function GET(
request: Request,
{ params }: { params: Promise<{ chain: string }> }
): Promise<NextResponse> {
const limited = await enforceRateLimit(request, 'read')
if (limited !== undefined) return limited

const chainApi = chainModule((await params).chain)
if (chainApi === undefined) return NextResponse.json({ error: 'unknown_chain' }, { status: 404 })

const account = new URL(request.url).searchParams.get('account') ?? ''
if (!isAccountOn(chainApi.chain, account)) {
return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
}

const asked = chainApi.venues.filter(
(venue) => venue.positions !== undefined || venue.kinds.some((kind) => HOLDS_MONEY.has(kind))
)
const readings = await Promise.all(asked.map((venue) => readPositionsBriefly(venue, account)))

const venues: VenueHoldings[] = []
const unread: PositionsReport['unread'] = []
asked.forEach(({ id, name, kinds }, i) => {
const positions = readings[i]
// A figure that isn't a number says nothing about what is there, so it isn't read as nothing.
if (positions === undefined || positions.some((p) => Number.isNaN(Number(p.amount)))) {
unread.push({ id, name })
return
}
venues.push({ id, name, kinds, positions: positions.filter((p) => Number(p.amount) > 0) })
})

const report: PositionsReport = { venues, unread }
return NextResponse.json(report, { headers: { 'cache-control': 'no-store' } })
}
51 changes: 48 additions & 3 deletions apps/dapp/app/api/chain/[chain]/prepare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,45 @@ 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', 'send']),
kind: z.enum([
'swap',
'supply',
'stake',
'send',
'withdraw',
'unstake',
'borrow',
'repay',
'stream',
'invoice',
'pay_invoice',
'claim',
'cancel',
]),
amount: z
.string()
.regex(/^\d+(\.\d+)?$/)
.regex(/^(\d+(\.\d+)?|all)$/)
.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(),
recipient: z.string().min(1).max(128).optional(),
durationDays: z
.string()
.regex(/^[1-9]\d{0,4}$/)
.optional(),
dueDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
invoiceId: z
.string()
.regex(/^\d{1,78}$/)
.optional(),
streamId: z
.string()
.regex(/^[A-Za-z0-9]{1,64}$/)
.optional(),
}),
})

Expand All @@ -44,7 +74,18 @@ export async function POST(
}

try {
const { kind, amount, tokenIn, tokenOut, venue, recipient } = parsed.data.request
const {
kind,
amount,
tokenIn,
tokenOut,
venue,
recipient,
durationDays,
dueDate,
invoiceId,
streamId,
} = parsed.data.request
const prepared = await prepareChainAction(
chainApi,
{
Expand All @@ -54,6 +95,10 @@ export async function POST(
...(tokenOut !== undefined ? { tokenOut } : {}),
...(venue !== undefined ? { venue } : {}),
...(recipient !== undefined ? { recipient } : {}),
...(durationDays !== undefined ? { durationDays } : {}),
...(dueDate !== undefined ? { dueDate } : {}),
...(invoiceId !== undefined ? { invoiceId } : {}),
...(streamId !== undefined ? { streamId } : {}),
},
parsed.data.account
)
Expand Down
11 changes: 0 additions & 11 deletions apps/dapp/app/api/lend/borrow/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextResponse } from 'next/server'

import { buildBlendBorrow, prepareBlendWithdraw } from '../../../../lib/lend/blend-client'
import { assertReserveWithinCap } from '../../../../lib/lend/cap'
import { explainPoolError, isTransientPoolError } from '../../../../lib/lend/pool-errors'
import { readReserveList } from '../../../../lib/lend/reserves'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
Expand Down Expand Up @@ -72,16 +71,6 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 })
}

// A borrow adds exposure the same as a supply does, so it is capped the same.
try {
await assertReserveWithinCap(body.asset, body.amount)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'over the mainnet trade cap' },
{ status: 400 }
)
}

try {
const built = await buildBlendBorrow({
account: body.account,
Expand Down
12 changes: 0 additions & 12 deletions apps/dapp/app/api/lend/build/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'

import { buildBlendSupply, prepareBlendSupply } from '../../../../lib/lend/blend-client'
import { explainPoolError } from '../../../../lib/lend/pool-errors'
import { assertReserveWithinCap } from '../../../../lib/lend/cap'
import { readReserveList } from '../../../../lib/lend/reserves'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
import { reportError } from '../../../../lib/server/report'
Expand Down Expand Up @@ -64,17 +63,6 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 })
}

// Against the mainnet cap; see `assertReserveWithinCap` for how a reserve is
// priced.
try {
await assertReserveWithinCap(body.asset, body.amount)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'over the mainnet trade cap' },
{ status: 400 }
)
}

try {
const built = await buildBlendSupply({
account: body.account,
Expand Down
16 changes: 1 addition & 15 deletions apps/dapp/app/api/lend/collateral/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
buildBlendCollateralWithdraw,
prepareBlendWithdraw,
} from '../../../../lib/lend/blend-client'
import { assertReserveWithinCap } from '../../../../lib/lend/cap'
import { explainPoolError } from '../../../../lib/lend/pool-errors'
import { readReserveList } from '../../../../lib/lend/reserves'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
Expand All @@ -32,7 +31,7 @@ interface CollateralBody {
asset?: string
/** Base units. Required to post; omit when reclaiming everything. */
amount?: string
/** 'post' adds collateral, 'reclaim' returns it to a plain supply balance. */
/** 'post' moves it from the wallet into the pool, 'reclaim' pays it back to the wallet. */
direction?: 'post' | 'reclaim'
}

Expand Down Expand Up @@ -75,19 +74,6 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: 'Could not read Blend reserves.' }, { status: 502 })
}

// Posting moves the user's funds into the pool, so it is capped. Reclaiming
// returns them to a plain supply balance of the same account and is not.
if (body.direction === 'post') {
try {
await assertReserveWithinCap(body.asset, body.amount as string)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'over the mainnet trade cap' },
{ status: 400 }
)
}
}

try {
const built =
body.direction === 'post'
Expand Down
4 changes: 0 additions & 4 deletions apps/dapp/app/api/lend/defindex/build/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ interface Body {
amount?: unknown
}

// No mainnet trade cap here. This venue is testnet-only (`networks` in
// lib/venues.ts) and its builder refuses any other network, so nothing is
// spent on mainnet through this route. Give it `assertTradeWithinCap` before
// the venue gains `networks: ['mainnet']`.
export async function POST(request: Request): Promise<NextResponse> {
const limited = await enforceRateLimit(request, 'build')
if (limited !== undefined) return limited
Expand Down
Loading