Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4badef4
docs: plan the agent race on every chain
0xDeon Nov 4, 2026
371f974
feat(dapp): let the agent race take a chain action
0xDeon Nov 4, 2026
d0bd890
fix(dapp): refuse a bad race amount before quoting
0xDeon Nov 4, 2026
e98d16e
feat(dapp): give agents each chain's own facts
0xDeon Nov 4, 2026
1f5c6ca
fix(dapp): keep an unpriced swap from winning and show dollars on cha…
0xDeon Nov 4, 2026
eee715a
feat(dapp): quote near venues for the agent race
0xDeon Nov 4, 2026
47fbf15
feat(dapp): run the agent race on near from chat
0xDeon Nov 4, 2026
bac07c1
fix(dapp): keep chain routes off the swap hook and close the race's s…
0xDeon Nov 4, 2026
80e0dac
fix(dapp): hold the signed latch for the whole race and check the wal…
0xDeon Nov 4, 2026
225af35
fix(dapp): report an unknown submit outcome as pending so the latch h…
0xDeon Nov 4, 2026
26019ea
fix(dapp): only call a solana submit clean when the answer proves not…
0xDeon Nov 4, 2026
1a5fe73
feat(dapp): race jupiter and cetus against 1click
0xDeon Nov 4, 2026
f67809a
fix(dapp): value 1click like every other route and answer inside the …
0xDeon Nov 4, 2026
2bf47ab
feat(dapp): swap and race on avalanche and monad through 1click
0xDeon Nov 4, 2026
ededd65
fix(dapp): bind an evm revert to this transfer and read the chain's n…
0xDeon Nov 4, 2026
1901c7b
fix(dapp): answer pending when an evm revert or amount can't be read
0xDeon Nov 4, 2026
b12fe60
feat(dapp): record the chain of every agent race
0xDeon Nov 4, 2026
b77e577
fix(dapp): read no stellar network when loading the chain name list
0xDeon Nov 4, 2026
c80ef06
fix(dapp): send a chain submit's own race id and match evm accounts b…
0xDeon Nov 4, 2026
d396497
fix(dapp): hold the card's venue on a second press and name the venue…
0xDeon Nov 4, 2026
e2d074c
fix(dapp): say which chain each race in an account's history ran on
0xDeon Nov 4, 2026
5a52aa0
fix(dapp): read an amount like .5 as 0.5 so its build isn't refused
0xDeon Nov 4, 2026
9ac07f9
fix(dapp): refuse a race with no venue and stop promising what a chai…
0xDeon Nov 4, 2026
c851aa7
fix(dapp): warn about a chain transfer already started, even after a …
0xDeon Nov 4, 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
6 changes: 5 additions & 1 deletion apps/dapp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ MONEYGRAM_PRODUCTION_HOME_DOMAIN=
# the transaction pooler, port 6543); localhost only works on your own machine.
DATABASE_URL=postgresql://intent:intent_local_dev@localhost:55432/intent
# Min 32 chars. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Also signs the 1Click deposits issued on Solana, Sui, Avalanche and Monad. Without it that route
# isn't offered, which leaves Avalanche and Monad with no swaps at all.
AUTH_SECRET=
# on | off. Whether the OTP gate in front of the app pages runs. PAUSED: only
# ACCESS_GATE=on turns it on, on any network. The API is outside
Expand Down Expand Up @@ -209,5 +211,7 @@ WEAVE_SECRET_KEY=
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 C-Chain RPC used to build and confirm USDC deposits, and to confirm 1Click swaps there.
AVALANCHE_RPC_URL=
# Monad RPC used to read balances and confirm 1Click swaps. Unset, viem's default public endpoint is used.
MONAD_RPC_URL=
246 changes: 204 additions & 42 deletions apps/dapp/app/api/agents/compete/route.ts

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions apps/dapp/app/api/agents/history/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { activeNetwork } from '@intent/config'
import { StrKey } from '@stellar/stellar-sdk'
import { NextResponse } from 'next/server'

import { isWalletAddress } from '../../../../lib/wallet/address'
import { databaseConfigured } from '../../../../lib/server/db'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
import { getAnalyticsRepo } from '../../../../lib/server/analytics'
Expand All @@ -13,6 +14,15 @@ export const dynamic = 'force-dynamic'
const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100

const STELLAR_SHAPE = /^G[A-Z2-7]{55}$/

/** A Stellar address is checked by its checksum; the other chains' by the shape the app accepts. */
function validAccount(account: string): boolean {
return STELLAR_SHAPE.test(account)
? StrKey.isValidEd25519PublicKey(account)
: isWalletAddress(account)
}

/**
* A wallet's own agent races, each with the winning agent's reasoning and
* the transaction this wallet actually submitted for it, when there was one.
Expand All @@ -34,7 +44,7 @@ export async function GET(request: Request): Promise<Response> {

const url = new URL(request.url)
const account = url.searchParams.get('account')
if (account === null || !StrKey.isValidEd25519PublicKey(account)) {
if (account === null || !validAccount(account)) {
return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
}

Expand All @@ -49,7 +59,8 @@ export async function GET(request: Request): Promise<Response> {
try {
const repo = await getAnalyticsRepo()
const result = await repo.historyForAccount({
network: activeNetwork(),
// Other chains' actions are logged as mainnet, whatever Stellar network the page is on.
network: STELLAR_SHAPE.test(account) ? activeNetwork() : 'mainnet',
account,
page,
pageSize,
Expand Down
48 changes: 35 additions & 13 deletions apps/dapp/app/api/chain/[chain]/submit/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CHAIN_DESCRIPTORS } from '@intent/config'
import { NextResponse } from 'next/server'
import { z } from 'zod'

Expand All @@ -10,6 +11,8 @@ import { isWalletAddress } from '../../../../../lib/wallet/address'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// A send waits for the network to confirm, and a 1Click swap then waits on 1Click too.
export const maxDuration = 60

const Signed = z.discriminatedUnion('family', [
z.object({ family: z.literal('solana'), signedTransaction: z.string().min(1).max(8192) }),
Expand All @@ -22,6 +25,8 @@ const Signed = z.discriminatedUnion('family', [
family: z.literal('near'),
hashes: z.array(z.string().min(1).max(128)).min(1).max(8),
}),
// The venue checks its shape, so an odd answer from a wallet that may have sent is read as pending.
z.object({ family: z.literal('evm'), hash: z.string().max(128) }),
])

const Body = z.object({
Expand Down Expand Up @@ -54,20 +59,37 @@ export async function POST(

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) {
// By family, since Avalanche and Monad are both EVM.
if (signed.family !== CHAIN_DESCRIPTORS[chainApi.chain].family) {
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)
// NEAR and EVM wallets have already sent, so there are no bytes left to check. Their venues
// ask the chain what was sent instead.
if (signed.family !== 'near' && signed.family !== 'evm') {
const payload: SigningPayload =
signed.family === 'solana'
? { family: 'solana', transaction: signed.signedTransaction }
: { family: 'sui', transaction: signed.transaction }
try {
venue.assertSafe(payload, account, context)
} catch (e) {
// This route is what sends the bytes, so any failure here means they never left.
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 transaction could not be checked, so nothing was sent. Build it again.',
},
{ status: 422 }
)
}
}

try {
const input = { account, venueId, signed, ...(context !== undefined ? { context } : {}) }
const result = await (venue.submit ?? chainApi.submit)(input)
await logExecution({
Expand All @@ -82,17 +104,17 @@ export async function POST(
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,
// Sending may have started, so no throw from here on can prove nothing moved.
// Sent with no outcome known: the same contract as the cash-out route.
pending: true,
error:
"This could not be confirmed. Check your wallet's activity before trying again, in case it went through.",
},
{ status: 502 }
{ status: 202 }
)
}
}
6 changes: 3 additions & 3 deletions apps/dapp/components/apps/venue-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Search, X } from 'lucide-react'
import { useMemo, useState } from 'react'

import { useChain } from '../../providers/chain-provider'
import { isListedOn, notOnNetworkLabel, venueDetail, venues } from '../../lib/venues'
import { isListedOn, notOnNetworkLabel, venueDetail, venueOnChain, venues } from '../../lib/venues'
import { categoryLabel, VenueCard, VenueLogo, VerifiedTick } from './venue-card'

/** Integrated first, then quoted, then merely listed. */
Expand Down Expand Up @@ -42,7 +42,7 @@ export function VenueGrid({
const rows = useMemo<Row[]>(
() =>
venues
.filter((v) => v.family === descriptor.family && isListedOn(v))
.filter((v) => venueOnChain(v, descriptor) && 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.
Expand All @@ -55,7 +55,7 @@ export function VenueGrid({
live: notHere === undefined && (venue.integration ?? 'listed') !== 'listed',
}
}),
[descriptor.family, availableIds]
[descriptor, availableIds]
)

const categories = useMemo(() => {
Expand Down
27 changes: 25 additions & 2 deletions apps/dapp/components/intents/chain-action-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ 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. */
/** Review, sign and result for an action on a chain other than Stellar. */
export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Element {
const { phase, prepared, error, hash, explorerUrl } = action
const { phase, prepared, error, hash, explorerUrl, earlier } = action
const busy = phase === 'signing' || phase === 'submitting'

return (
Expand Down Expand Up @@ -74,6 +74,29 @@ export function ChainActionConfirm({ action }: { action: ChainAction }): JSX.Ele
</ul>
) : null}

{phase === 'review' && earlier !== undefined ? (
<p role="alert" className="flex flex-wrap items-center gap-2 text-sm leading-relaxed">
{earlier.text}
{earlier.explorerUrl !== undefined && earlier.hash !== undefined ? (
<a
href={earlier.explorerUrl}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline underline-offset-2"
>
{`${earlier.hash.slice(0, 6)}…${earlier.hash.slice(-4)}`}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
) : null}
</p>
) : null}

{phase === 'review' && error !== undefined ? (
<p role="alert" className="text-sm leading-relaxed">
{error}
</p>
) : null}

{phase === 'settled' ? (
<p className="flex items-center gap-2 text-sm font-medium" role="status">
Sent
Expand Down
34 changes: 28 additions & 6 deletions apps/dapp/components/intents/competition-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion'
import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react'

import type { CompetitionState } from '../../lib/agents/competition'
import { panelErrorMessage, recommendationLine, singleRouteNote } from '../../lib/agents/chain-race'
import { describePlan } from '../../lib/agents/competition'
import { AgentAvatar } from '../agents/agent-avatar'
import { agentBubble, systemPill } from './bubble'
Expand Down Expand Up @@ -84,6 +85,9 @@ export function CompetitionPanel({
onExecute,
executingKey,
locked = false,
chainName,
directRoute = false,
signed = false,
}: {
state: CompetitionState
onExecute: (key: string) => void
Expand All @@ -95,6 +99,12 @@ export function CompetitionPanel({
* disabling the buttons on the first pick made it one.
*/
locked?: boolean
/** The chain's name, set off Stellar so a single route can be explained. */
chainName?: string
/** The direct route is being offered instead, so the error must not say agents can't act here. */
directRoute?: boolean
/** Something may have been signed, so "nothing was signed" must not be said. */
signed?: boolean
}): JSX.Element | null {
const { proposals, revealed, agents, phase, winner, error } = state
const unanimous = state.unanimous === true
Expand All @@ -106,19 +116,25 @@ export function CompetitionPanel({
// race that was not happening and never ended.
if (phase === 'idle') return null

// A refusal of the action itself is shown by the chat, as written, in the
// action card; saying "agents are not online" over it would be wrong.
if (error?.code === 'action_refused') return null

if (error !== undefined) {
return (
<div className={cn(agentBubble, 'flex items-start gap-3 border-dashed')}>
<WifiOff className="mt-0.5 h-4 w-4 shrink-0 opacity-60" />
<div>
<p className="font-medium">
{error.code === 'chain_unsupported'
? 'Agents cannot execute here'
? directRoute
? 'The agents did not race'
: 'Agents cannot execute here'
: error.code === 'no_agent_answered'
? 'No agent answered'
: 'Agents are not online'}
</p>
<p className="mt-1 opacity-70">{error.message}</p>
<p className="mt-1 opacity-70">{panelErrorMessage(error.message, signed)}</p>
</div>
</div>
)
Expand All @@ -133,8 +149,12 @@ export function CompetitionPanel({
const pendingAgents = decided ? [] : agents.filter((a) => !revealed[a.key] && answered(a.key))
const anyAnswered = Object.values(proposals).some((p) => p.failed === undefined)

const routeNote =
chainName !== undefined ? singleRouteNote(state.routeCount, chainName) : undefined

return (
<div className="flex flex-col gap-4">
{routeNote !== undefined ? <div className={systemPill}>{routeNote}</div> : null}
{revealedAgents.length === 0 && !decided ? (
<div className={systemPill}>
<Radio className="h-3.5 w-3.5 animate-pulse motion-reduce:animate-none" />
Expand Down Expand Up @@ -241,11 +261,12 @@ export function CompetitionPanel({
oracle's fair value. */}
<div className="flex flex-col text-xs tabular-nums">
<span className="font-semibold">
{money(proposal?.avgPriceUsd ?? 0)} avg
{proposal?.figures?.headline ??
`${money(proposal?.avgPriceUsd ?? 0)} avg`}
</span>
<span className="opacity-60">
{[
vsOracle(proposal?.vsOraclePct),
proposal?.figures !== undefined ? '' : vsOracle(proposal?.vsOraclePct),
proposal?.executionMode === 'rest' &&
proposal.horizonMinutes !== undefined &&
proposal.horizonMinutes > 0
Expand Down Expand Up @@ -311,10 +332,11 @@ export function CompetitionPanel({
{/* Recommended on the measured fill, which is the only thing scored.
"Strongest strategy" implied a judgement of the reasoning; there
is none — the route's real output decides, and a tie is broken
by a draw the agents cannot influence. */}
by a draw the agents cannot influence. A supply or a stake is
ranked on its rate instead, and the line says that rate. */}
{unanimous
? `All ${Object.values(proposals).filter((p) => p.failed === undefined).length} agents agree: ${describePlan(proposals[winner] ?? {})}. Pick any to execute.`
: `Recommended: ${proposals[winner]?.name}, best measured fill. You pick who executes.`}
: recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)}
</motion.div>
) : null}
</div>
Expand Down
Loading