Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1e8e136
docs: plan multi-step transactions on every chain
0xDeon Nov 4, 2026
faecfd9
feat(dapp): show each payout currency's country flag
0xDeon Nov 4, 2026
0820643
feat(dapp): read every token a wallet holds on each chain
0xDeon Nov 4, 2026
1545f84
fix(dapp): read held tokens from the chain, bound the read and drop s…
0xDeon Nov 4, 2026
4f50614
fix(dapp): never leave the chat busy after a dropped balance reply, a…
0xDeon Nov 4, 2026
725033c
fix(dapp): show a disconnected person's balance reply instead of a wa…
0xDeon Nov 4, 2026
ff736b8
feat(dapp): keep the chat until a new chat starts
0xDeon Nov 4, 2026
15d23c1
fix(dapp): start a switched wallet fresh and keep private figures out…
0xDeon Nov 4, 2026
53ba04b
fix(dapp): make the wallet switch notice true after a cash-out is paid
0xDeon Nov 4, 2026
6d3a4fe
feat(dapp): read a swap and what comes after it
0xDeon Nov 4, 2026
a820022
fix(dapp): keep a refused currency, say what reached the wallet, and …
0xDeon Nov 4, 2026
0128bc6
feat(dapp): swap then cash out on solana, sui and avalanche
0xDeon Nov 4, 2026
a67ea86
fix(dapp): check a chain sequence's words, balance and size before th…
0xDeon Nov 4, 2026
501f601
fix(dapp): read a chain's name as its token in what a person types
0xDeon Nov 4, 2026
43b9f55
feat(dapp): swap then deposit, stake or send on every chain
0xDeon Nov 4, 2026
16327e7
fix(dapp): close the race once a sequence's swap settles, and check w…
0xDeon Nov 4, 2026
3f73acc
fix(dapp): read no thousands from a leading zero, and no swap from a …
0xDeon Nov 4, 2026
8a797d2
feat(dapp): read multi-step requests in ordinary words on every chain
0xDeon Nov 4, 2026
f7cee9a
feat(dapp): hold a chain's readings to its own tokens, and gate the t…
0xDeon Nov 4, 2026
68ffc8e
fix(dapp): keep a swap's order in the person's words, and stop tellin…
0xDeon Nov 5, 2026
0d42660
fix(dapp): keep step 2 from offering the swap again, and stop near ca…
0xDeon Nov 5, 2026
d375023
fix(dapp): report a follow-on the server can't honour, off stellar only
0xDeon Nov 5, 2026
2e7f0dc
fix(dapp): send ordinary words to the model, and hold step 2 to what …
0xDeon Nov 5, 2026
3134419
fix(dapp): pass no figure with a decimal comma, and say why a message…
0xDeon Nov 5, 2026
4d071dc
fix(dapp): size dollar amounts at a live price, and stop reading $5 o…
0xDeon Nov 5, 2026
42fb856
fix(dapp): let no model choose between dollars and a count, and price…
0xDeon Nov 5, 2026
7433891
fix(dapp): say which agents didn't answer, and tell them a later step…
0xDeon Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dapp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ ACCESS_GATE=
# the client's copy. Behind any other proxy that only appends to
# x-forwarded-for, a client can choose its own bucket.
# RATE_LIMIT_BUILD=30/60
# RATE_LIMIT_READ=120/60
# RATE_LIMIT_SUBMIT=10/60
# RATE_LIMIT_COMPETE=10/60
# RATE_LIMIT_RESOLVE=30/60
Expand Down
20 changes: 4 additions & 16 deletions apps/dapp/app/[chain]/intents/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,14 @@ import { parseChainSegment } from '@intent/config'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

import { IntentChat } from '../../../../components/intents/intent-chat'
import { intentIntro } from '../../../../lib/intent-intro-copy'

export const metadata: Metadata = { title: 'New intent' }

/** The chat is drawn by `ChatHost` in the chain layout, under this page's own heading. */
export default async function NewIntentPage({
params,
}: {
params: Promise<{ chain: string }>
}): Promise<JSX.Element> {
const route = parseChainSegment((await params).chain)
if (route === undefined) notFound()

return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:px-6 sm:py-10">
<div className="mb-6">
<h1 className="font-display text-3xl font-semibold tracking-tight">Submit an intent</h1>
<p className="text-muted-foreground mt-1 text-sm">{intentIntro(route.slug)}</p>
</div>
<IntentChat />
</div>
)
}): Promise<null> {
if (parseChainSegment((await params).chain) === undefined) notFound()
return null
}
22 changes: 4 additions & 18 deletions apps/dapp/app/[chain]/intents/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
'use client'

import { IntentChat } from '../../../components/intents/intent-chat'

/**
* The composer, and only the composer.
* The composer, and only the composer: `ChatHost` in the chain layout draws it,
* so the chat stays mounted when the person goes to another page.
*
* History used to sit behind a second tab here, which hid a whole page of
* settled trades and open positions behind a label most people never
* clicked. It has its own page now, one step away in the sidebar.
*/
export default function IntentsPage(): JSX.Element {
return (
<div className="mx-auto max-w-4xl px-4 py-6 sm:px-6 sm:py-10">
<div className="mb-6">
<h1 className="font-display text-3xl font-semibold tracking-tight">Intents</h1>
<p className="text-muted-foreground mt-1 text-sm">
Say what you want. Agents compete to deliver it.
</p>
</div>

<IntentChat />
</div>
)
export default function IntentsPage(): null {
return null
}
5 changes: 4 additions & 1 deletion apps/dapp/app/[chain]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
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 { ChainProvider } from '../../providers/chain-provider'
Expand Down Expand Up @@ -46,7 +47,9 @@ export default async function ChainLayout({
return (
<ChainProvider segment={chain}>
<WalletGate bypass={bypass}>
<AppShell tradeCapUsd={cap}>{children}</AppShell>
<AppShell tradeCapUsd={cap}>
<ChatHost key={chain}>{children}</ChatHost>
</AppShell>
</WalletGate>
</ChainProvider>
)
Expand Down
13 changes: 12 additions & 1 deletion apps/dapp/app/api/agents/compete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import { benchedUntilFor, lineUp, noteRateLimited } from '../../../../lib/agents
import { getRoster } from '../../../../lib/agents/registry'
import type { PlanContext } from '../../../../lib/agents/plan-scoring'
import { pickWinner, scoreProposals, unanimousChoice } from '../../../../lib/agents/scoring'
import { assertAmountWithinCap, assertKnownTokens } from '../../../../lib/chains/prepare'
import {
assertAmountWithinCap,
assertKnownTokens,
assertNativeReserve,
} from '../../../../lib/chains/prepare'
import { chainModule } from '../../../../lib/chains/registry'
import {
ChainActionRefused,
Expand Down Expand Up @@ -144,6 +148,13 @@ async function chainRaceFacts(
if (account === undefined) {
return 'No wallet account came with this request, so nothing could be priced. Nothing was signed or sent.'
}
try {
await assertNativeReserve(chainApi, action, account)
} catch (e) {
// Worded in full already, ending as every refusal here does.
if (e instanceof ChainActionRefused) return { refusal: e.message }
throw e
}

const routes = await quoteChainRoutes(chainApi, action, account)
if (routes.length === 0) {
Expand Down
34 changes: 34 additions & 0 deletions apps/dapp/app/api/chain/[chain]/balances/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'

import { chainModule } from '../../../../../lib/chains/registry'
import { BALANCE_DEADLINE_MS, ChainActionRefused } from '../../../../../lib/chains/types'
import { enforceRateLimit } from '../../../../../lib/server/rate-limit'
import { reportError } from '../../../../../lib/server/report'

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

/** Every token the account holds 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') ?? ''
try {
const balances = await chainApi.balances(account, AbortSignal.timeout(BALANCE_DEADLINE_MS))
return NextResponse.json(balances, { headers: { 'cache-control': 'no-store' } })
} catch (e) {
if (e instanceof ChainActionRefused) {
return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
}
reportError('chain/balances', e, { chain: chainApi.chain })
return NextResponse.json({ error: 'balances_unavailable' }, { status: 503 })
}
}
64 changes: 64 additions & 0 deletions apps/dapp/app/api/chain/[chain]/follow-on/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'

import { assertFollowOnCanRun } 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 NOTHING_MOVED = 'Nothing was signed or sent.'

const Body = z.object({
account: z.string().min(1).max(128),
step: z.object({
kind: z.enum(['supply', 'stake', 'send']),
tokenIn: z.string().min(1).max(16),
venue: z.string().min(1).max(40).optional(),
recipient: z.string().min(1).max(128).optional(),
}),
})

/** Whether a step could run after a swap, asked before the swap is signed. Builds nothing. */
export async function POST(
request: Request,
{ params }: { params: Promise<{ chain: string }> }
): Promise<NextResponse> {
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, tokenIn, venue, recipient } = parsed.data.step
await assertFollowOnCanRun(
chainApi,
{
kind,
tokenIn,
...(venue !== undefined ? { venue } : {}),
...(recipient !== undefined ? { recipient } : {}),
},
parsed.data.account
)
return NextResponse.json({ ok: true })
} catch (e) {
if (e instanceof ChainActionRefused) {
const said = e.message.endsWith(NOTHING_MOVED) ? e.message : `${e.message} ${NOTHING_MOVED}`
return NextResponse.json({ error: said }, { status: 422 })
}
reportError('chain/follow-on', e, { chain: chainApi.chain })
return NextResponse.json({ error: 'unreadable' }, { status: 502 })
}
}
6 changes: 4 additions & 2 deletions apps/dapp/app/api/chain/[chain]/prepare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ 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']),
kind: z.enum(['swap', 'supply', 'stake', 'send']),
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(),
recipient: z.string().min(1).max(128).optional(),
}),
})

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

try {
const { kind, amount, tokenIn, tokenOut, venue } = parsed.data.request
const { kind, amount, tokenIn, tokenOut, venue, recipient } = parsed.data.request
const prepared = await prepareChainAction(
chainApi,
{
Expand All @@ -52,6 +53,7 @@ export async function POST(
tokenIn,
...(tokenOut !== undefined ? { tokenOut } : {}),
...(venue !== undefined ? { venue } : {}),
...(recipient !== undefined ? { recipient } : {}),
},
parsed.data.account
)
Expand Down
38 changes: 38 additions & 0 deletions apps/dapp/app/api/chain/[chain]/price/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'

import { chainModule } from '../../../../../lib/chains/registry'
import { tokenBySymbol } from '../../../../../lib/chains/types'
import { enforceRateLimit } from '../../../../../lib/server/rate-limit'
import { reportError } from '../../../../../lib/server/report'

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

/** A listed token's dollar price, from the same source the race uses, to size "$5 of SOL". */
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 token = tokenBySymbol(chainApi.tokens, new URL(request.url).searchParams.get('token') ?? '')
if (token === undefined) return NextResponse.json({ error: 'unknown_token' }, { status: 404 })

try {
const priceUsd = await chainApi.priceUsd(token)
if (priceUsd === undefined) {
return NextResponse.json({ error: 'price_unavailable' }, { status: 503 })
}
return NextResponse.json(
{ priceUsd, decimals: token.decimals },
{ headers: { 'cache-control': 'no-store' } }
)
} catch (e) {
reportError('chain/price', e, { chain: chainApi.chain })
return NextResponse.json({ error: 'price_unavailable' }, { status: 503 })
}
}
45 changes: 36 additions & 9 deletions apps/dapp/app/api/intent/orchestrate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { z } from 'zod'

import {
LENDABLE_ONLY,
chainReadPolicy,
tokenHintIn,
OFFRAMP_ANCHORS,
lendingVenuesOn,
unknownTickers,
} from '../../../../lib/intent-read-policy'
import { SEQUENCE_CASHOUT_CHAINS } from '../../../../lib/chains/sequence'
import { orchestrateWithLlm } from '../../../../lib/orchestrate/orchestrate'
import { isLlmParseConfigured } from '../../../../lib/parse-intent-llm'
import { logIntentRead } from '../../../../lib/server/analytics'
import { cashOutPrompt } from '../../../../lib/weave/corridors'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
import { resolveVerifiedAsset, tradeableSymbols } from '../../../../lib/swap/asset-registry'
import { ensureLiveAssets, tickersIn } from '../../../../lib/swap/live-assets'
Expand Down Expand Up @@ -74,9 +78,12 @@ export async function POST(request: Request): Promise<NextResponse> {
// The latest thing the user typed is what names a token for the first time.
const latest = [...turns].reverse().find((t) => t.role === 'user')?.content ?? ''

// Off Stellar a reading is held to the chain's own tokens and venues, not Stellar's registry.
const onChain = chainReadPolicy(chain)

// A token named in the sentence that the app does not hold by hand is looked
// up and, only if its issuer proves itself, admitted for this request.
await ensureLiveAssets(tickersIn(latest))
if (onChain === undefined) await ensureLiveAssets(tickersIn(latest))

const result = await orchestrateWithLlm(turns, {
chain,
Expand All @@ -85,15 +92,29 @@ export async function POST(request: Request): Promise<NextResponse> {
// The model may only name assets this app can actually trade. Checked here
// rather than trusted, because a hallucinated ticker reaching the quoter as
// a real instruction is a trap this codebase has hit once already.
allowedSymbols: [...tradeableSymbols(), ...(LENDABLE_ONLY[chain] ?? [])],
allowedVenues: lendingVenuesOn(chain),
allowedAnchors: OFFRAMP_ANCHORS[chain] ?? [],
allowedSymbols: onChain?.symbols ?? [...tradeableSymbols(), ...(LENDABLE_ONLY[chain] ?? [])],
allowedVenues: onChain?.venues ?? lendingVenuesOn(chain),
// The only payout off Stellar is Weave's, and only on a chain whose swaps can end in one.
allowedAnchors:
onChain === undefined
? (OFFRAMP_ANCHORS[chain] ?? [])
: (SEQUENCE_CASHOUT_CHAINS as readonly string[]).includes(chain) &&
cashOutPrompt(chain) !== ''
? ['weave']
: [],
// Off Stellar a follow-on that can't be honoured is refused before the swap, not dropped.
...(onChain !== undefined ? { reportRefusedFollowOn: true } : {}),
})

if (result === null) {
// A ticker typed in capitals that nothing here recognises is most likely
// the reason. Saying so lets the chat ask which token was meant.
const unknown = unknownTickers(latest)
const hint = onChain === undefined ? undefined : tokenHintIn(chain, latest)
if (hint !== undefined) {
await logIntentRead({ understood: false, reason: 'unclear' })
return NextResponse.json({ outcome: 'unclear', reply: hint })
}
const unknown = onChain === undefined ? unknownTickers(latest) : []
if (unknown.length > 0) {
await logIntentRead({ understood: false, reason: 'unknown_token' })
return NextResponse.json({ outcome: 'unavailable', reason: 'unknown_token', tokens: unknown })
Expand Down Expand Up @@ -164,16 +185,22 @@ export async function POST(request: Request): Promise<NextResponse> {
// Null rather than omitted, so the client distinguishes "no follow-on"
// from a field that failed to serialise.
followOn: reading.followOn,
...(reading.followOnRefused !== undefined
? { followOnRefused: reading.followOnRefused }
: {}),
style: result.style,
...(result.amountOf !== undefined ? { amountOf: result.amountOf } : {}),
...(result.limitPriceUsd !== undefined ? { limitPriceUsd: result.limitPriceUsd } : {}),
instruction: result.instruction,
context: result.context,
// Tokens admitted live, so the page can trade them without a second lookup.
liveAssets: [reading.tokenIn, reading.tokenOut].flatMap((symbol) => {
const asset = resolveVerifiedAsset(symbol)
return asset !== undefined && asset.category === 'token' ? [asset] : []
}),
liveAssets:
onChain !== undefined
? []
: [reading.tokenIn, reading.tokenOut].flatMap((symbol) => {
const asset = resolveVerifiedAsset(symbol)
return asset !== undefined && asset.category === 'token' ? [asset] : []
}),
})
}
}
Expand Down
Loading